Skip to content

Commit

Permalink
Added SASL Auth mechanisms
Browse files Browse the repository at this point in the history
  • Loading branch information
naqvis committed Sep 12, 2019
1 parent 7ed4426 commit c7ceb33
Show file tree
Hide file tree
Showing 14 changed files with 482 additions and 65 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ config = XMPP::Config.new(
host: "localhost",
jid: "test@localhost",
password: "test",
log_file: STDOUT # Capture all out-going and in-coming messages
log_file: STDOUT, # Capture all out-going and in-coming messages
# Order of SASL Authentication Mechanism, first matched method supported by server will be used
# for authentication. Below is default order that will be used if `sasl_auth_order` param is not set.
sasl_auth_order: [XMPP::AuthMechanism::SCRAM_SHA_512, XMPP::AuthMechanism::SCRAM_SHA_256,
XMPP::AuthMechanism::SCRAM_SHA_1, XMPP::AuthMechanism::DIGEST_MD5,
XMPP::AuthMechanism::PLAIN, XMPP::AuthMechanism::ANONYMOUS]

router = XMPP::Router.new

Expand Down
110 changes: 79 additions & 31 deletions src/xmpp/auth.cr
Original file line number Diff line number Diff line change
@@ -1,45 +1,65 @@
require "base64"
require "./auth/*"

module XMPP
private module Auth
extend self

def auth_sasl(io, features, user, password)
# TODO: implement other type of SASL Authentication
have_plain = false
features.mechanisms.try &.mechanism.each do |m|
if m == "PLAIN"
have_plain = true
break
class AuthenticationError < Exception; end

enum AuthMechanism
SCRAM_SHA_512
SCRAM_SHA_256
SCRAM_SHA_1
DIGEST_MD5
PLAIN
ANONYMOUS

def to_s
s = AuthMechanism.names[self.value]
s.gsub("_", "-")
end
end

SASL_AUTH_ORDER = [AuthMechanism::SCRAM_SHA_512, AuthMechanism::SCRAM_SHA_256, AuthMechanism::SCRAM_SHA_1,
AuthMechanism::DIGEST_MD5, AuthMechanism::PLAIN,
AuthMechanism::ANONYMOUS]

private class AuthHandler
@io : IO
@password : String
@jid : JID
@features : Stanza::StreamFeatures

def initialize(@io, @features, @password, @jid)
raise AuthenticationError.new "Server returned empty list of Authentication mechanisms" unless (@features.mechanisms.try &.mechanism.size || 0) > 0
end

def authenticate(methods : Array(AuthMechanism))
if (mechanisms = @features.mechanisms.try &.mechanism)
methods.each do |method|
if mechanisms.includes? method.to_s
return do_auth(method)
end
end
raise AuthenticationError.new "None of the preferred Auth mechanism '[#{methods.join(",")}]' supported by server. Server supported mechanisms are [#{mechanisms.join(",")}]"
else
raise AuthenticationError.new "Server returned empty list of Authentication mechanisms"
end
raise "PLAIN authentication is not supported by server: [#{features.mechanisms.try &.mechanism.join}]" unless have_plain
auth_plain io, user, password
end

# Plain authentication: send base64-encoded \x00 user \x00 password
def auth_plain(io, user, password)
raw = "\x00#{user}\x00#{password}"
enc = Base64.encode(raw)
xml = sprintf "<auth xmlns='%s' mechanism='PLAIN'>%s</auth>", Stanza::NS_SASL, enc
io.write xml.to_slice

# Next message should be either success or failure
val = Stanza::Parser.next_packet read_resp(io)
if val.is_a?(Stanza::SASLSuccess)
# we are good
elsif val.is_a?(Stanza::SASLFailure)
# v.Any is type of sub-element in failure, which gives a description of what failed
v = val.as(Stanza::SASLFailure)
raise "auth failure: #{v.any.try &.to_xml}"
private def do_auth(method : AuthMechanism)
case method
when AuthMechanism::DIGEST_MD5 then auth_digest_md5
when AuthMechanism::PLAIN then auth_plain
when AuthMechanism::ANONYMOUS then auth_anonymous
when AuthMechanism::SCRAM_SHA_1 then auth_scram("sha1")
when AuthMechanism::SCRAM_SHA_256 then auth_scram("sha256")
when AuthMechanism::SCRAM_SHA_512 then auth_scram("sha512")
else
raise "expected SASL success or failure, got #{val.name}"
raise AuthenticationError.new "Auth mechanism '#{method.to_s}' not implemented. Currently implemented mechanisms are [#{SASL_AUTH_ORDER.join(",")}]"
end
end

private def read_resp(io)
private def read_resp
b = Bytes.new(1024)
io.read(b)
@io.read(b)
xml = String.new(b)
document = XML.parse(xml)
if (r = document.first_element_child)
Expand All @@ -48,5 +68,33 @@ module XMPP
raise "Invalid response from server: #{document.to_xml}"
end
end

private def send(xml : String)
@io.write xml.to_slice
end

private def send(packet : Stanza::Packet)
send(packet.to_xml)
end

private def handle_resp(tag)
# Next message should be either success or failure
val = Stanza::Parser.next_packet read_resp
if val.is_a?(Stanza::SASLSuccess)
# we are good
elsif val.is_a?(Stanza::SASLFailure)
# v.Any is type of sub-element in failure, which gives a description of what failed
v = val.as(Stanza::SASLFailure)
raise AuthenticationError.new "#{tag} - auth failure: #{v.any.try &.to_xml}"
else
raise AuthenticationError.new "#{tag} - expected SASL success or failure, got #{val.name}"
end
end

private def nonce(n : Int32)
b = Bytes.new(n)
Random.new.random_bytes(b)
Base64.strict_encode(b)
end
end
end
9 changes: 9 additions & 0 deletions src/xmpp/auth/anonymous.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module XMPP
private class AuthHandler
# Anonymous Auth
def auth_anonymous
send Stanza::SASLAuth.new(mechanism: "ANONYMOUS")
handle_resp("anonymous")
end
end
end
93 changes: 93 additions & 0 deletions src/xmpp/auth/digest-md5.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
require "base64"
require "openssl/md5.cr"
require "digest/md5.cr"

module XMPP
private class AuthHandler
# DIGEST-MD5 Auth - https://wiki.xmpp.org/web/SASL_and_DIGEST-MD5
def auth_digest_md5
send Stanza::SASLAuth.new(mechanism: "DIGEST-MD5")
val = Stanza::Parser.next_packet read_resp
if val.is_a?(Stanza::SASLChallenge)
challenge = val.as(Stanza::SASLChallenge).body
body = do_response(parse_digest_challenge(challenge))
send Stanza::SASLResponse.new(body)
val = Stanza::Parser.next_packet read_resp
if val.is_a?(Stanza::SASLChallenge)
# we are good
challenge = val.as(Stanza::SASLChallenge).body
Logger.info("digest-md5 - Auth successful: #{Base64.decode_string(challenge)}")
handle_success
elsif val.is_a?(Stanza::SASLFailure)
v = val.as(Stanza::SASLFailure)
raise "digest-md5 - auth failure: #{v.any.try &.to_xml}"
else
raise "digest-md5 - expected SASL success or failure, got #{val.name}"
end
else
raise "digest-md5 - Expecting challenge, got : #{val.to_xml}"
end
end

private def parse_digest_challenge(challenge : String)
val = Base64.decode_string(challenge)
res = Hash(String, String).new
val.split(",").each do |v|
pair = v.split("=")
key, val = pair[0], pair[1].strip('"')
next if key == "qop" && val != "auth"
raise "Invalid challenge. algorithm provided multiple times" if key == "algorithm" && res.has_key?("algorithm")
raise "Invalid challenge. charset provided multiple times" if key == "charset" && res.has_key?("charset")
res[key] = val
end
res["realm"] = @jid.domain unless res.has_key?("realm")
raise "Invalid challenge. nonce not found" unless res.has_key?("nonce")
raise "Invalid challenge. qop not found" unless res.has_key?("qop")
raise "Invalid challenge. algorithm not found" unless res.has_key?("algorithm")
res
end

private def do_response(challenge)
res = Hash{
"nonce" => challenge["nonce"],
"charset" => challenge["charset"],
"username" => @jid.node,
"realm" => challenge["realm"],
"cnonce" => nonce(16),
"nc" => "00000001",
"qop" => challenge["qop"],
"digest-uri" => "xmpp/#{@jid.domain}",
}
res["response"] = make_response(res)
vals = %w(nc qop response charset)
sb = String.build do |str|
res.each do |k, v|
str << k << "="
if !vals.includes?(k)
str << "\"" << v << "\""
else
str << v
end
str << ","
end
end
Base64.strict_encode(sb.rstrip(","))
end

private def make_response(res)
x = "#{res["username"]}:#{res["realm"]}:#{@password}"
y = String.new(OpenSSL::MD5.hash(x).to_slice)
a1 = "#{y}:#{res["nonce"]}:#{res["cnonce"]}"
a2 = "AUTHENTICATE:#{res["digest-uri"]}"
ha1 = Digest::MD5.hexdigest(a1)
ha2 = Digest::MD5.hexdigest(a2)
kd = "#{ha1}:#{res["nonce"]}:#{res["nc"]}:#{res["cnonce"]}:#{res["qop"]}:#{ha2}"
Digest::MD5.hexdigest(kd)
end

private def handle_success
send Stanza::SASLResponse.new
handle_resp("digest-md5")
end
end
end
13 changes: 13 additions & 0 deletions src/xmpp/auth/plain.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
require "base64"

module XMPP
private class AuthHandler
# Plain authentication: send base64-encoded \x00 user \x00 password
def auth_plain
raw = "\x00#{@jid.node}\x00#{@password}"
enc = Base64.encode(raw)
send Stanza::SASLAuth.new(mechanism: "PLAIN", body: enc)
handle_resp("plain")
end
end
end
134 changes: 134 additions & 0 deletions src/xmpp/auth/scram-sha.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
require "openssl/pkcs5"
require "openssl/hmac"
require "openssl/sha1"

module XMPP
private class AuthHandler
def auth_scram(method : String)
case method
when "sha256"
auth_scram_sha("SCRAM-SHA-256", OpenSSL::Algorithm::SHA256)
when "sha512"
auth_scram_sha("SCRAM-SHA-512", OpenSSL::Algorithm::SHA512)
else
auth_scram_sha("SCRAM-SHA-1", OpenSSL::Algorithm::SHA1)
end
end

# X-SCRAM_SHA_X Auth - https://wiki.xmpp.org/web/SASL_and_SCRAM-SHA-1
def auth_scram_sha(name, algorithm)
nonce = nonce(16)
msg = "n=#{escape(@jid.node || "")},r=#{nonce}"
raw = "n,,#{msg}"
enc = Base64.strict_encode(raw)
send Stanza::SASLAuth.new(mechanism: name, body: enc)
val = Stanza::Parser.next_packet read_resp
if val.is_a?(Stanza::SASLChallenge)
body = val.as(Stanza::SASLChallenge).body
server_resp = Base64.decode_string(body)
challenge = parse_scram_challenge(body, nonce)
resp, server_sig = scram_response(msg, server_resp, challenge, algorithm)

send Stanza::SASLResponse.new(resp)
val = Stanza::Parser.next_packet read_resp
if val.is_a?(Stanza::SASLSuccess)
# we are good
body = val.as(Stanza::SASLSuccess).body
sig = Base64.decode_string(body)
raise AuthenticationError.new "Server returned invalid signature on success" unless sig.starts_with?("v=")
raise AuthenticationError.new "Server returned signature mismatch." unless sig[2..] == server_sig
Logger.info("#{name} - Auth successful: #{sig}")
elsif val.is_a?(Stanza::SASLFailure)
v = val.as(Stanza::SASLFailure)
raise AuthenticationError.new "#{name} - auth failure: #{v.any.try &.to_xml}"
else
raise AuthenticationError.new "#{name} - expected SASL success or failure, got #{val.name}"
end
else
if val.is_a?(Stanza::SASLFailure)
v = val.as(Stanza::SASLFailure)
raise AuthenticationError.new "Selected mechanism [#{name}] is not supported by server" if v.type == "invalid-mechanism"
end
raise AuthenticationError.new "#{name} - Expecting challenge, got : #{val.to_xml}"
end
end

private def hash_func(algorithm)
if algorithm.sha256?
f = "SHA256"
else
f = "SHA1"
end
OpenSSL::Digest.new(f)
end

private def scram_response(initial_msg, server_resp, challenge, algorithm)
bare_msg = "c=biws,r=#{challenge["r"]}"
server_salt = Base64.decode(challenge["s"])
hasher = hash_func(algorithm)
salted_pwd = OpenSSL::PKCS5.pbkdf2_hmac(secret: @password, salt: server_salt, iterations: challenge["i"].to_i32,
algorithm: algorithm, key_size: hasher.digest_size)
client_key = OpenSSL::HMAC.digest(algorithm: algorithm, key: salted_pwd, data: "Client Key")

# stored_key = OpenSSL::SHA1.hash(client_key.to_unsafe, LibC::SizeT.new(client_key.bytesize))
hasher.update(client_key)
stored_key = hasher.digest

auth_msg = "#{initial_msg},#{server_resp},#{bare_msg}"
client_sig = OpenSSL::HMAC.digest(algorithm: algorithm, key: stored_key, data: auth_msg)
client_proof = xor(client_key, client_sig)

server_key = OpenSSL::HMAC.digest(algorithm: algorithm, key: salted_pwd, data: "Server Key")
server_sig = OpenSSL::HMAC.digest(algorithm: algorithm, key: server_key, data: auth_msg)

final_msg = "#{bare_msg},p=#{Base64.strict_encode(client_proof)}"
{Base64.strict_encode(final_msg), Base64.strict_encode(server_sig)}
end

private def parse_scram_challenge(challenge, nonce)
value = Base64.decode_string(challenge)
res = Hash(String, String).new
value.split(",").each do |v|
pair = v.split("=")
key, val = pair[0], v[2..]
res[key] = val
end
# RFC 5802:
# m: This attribute is reserved for future extensibility. In this
# version of SCRAM, its presence in a client or a server message
# MUST cause authentication failure when the attribute is parsed by
# the other end.
raise "Server sent reserved attribute 'm'" if res.has_key?("m")
if (i = res["i"]?)
raise "Server sent invalid iteration count" if i.to_i?.nil?
else
raise "Server didn't sent iteration count"
end
if (salt = res["s"]?)
raise "Server sent empty salt" if salt.blank?
# res["s"] = Base64.decode_string(salt)

This comment has been minimized.

Copy link
@alexanderadam

alexanderadam Sep 13, 2019

same here: is this comment still needed? 🤔

This comment has been minimized.

Copy link
@naqvis

naqvis Sep 14, 2019

Author Owner

Thanks @alexanderadam. Going to clean up and update repo

This comment has been minimized.

Copy link
@alexanderadam

alexanderadam Sep 14, 2019

No pressure here. 😉 I was just curious.

If I may ask: did you implement a XMPP library before?
Because the auth code looks nontrivial and you implemented it in, for OSS, a relatively short amount of time.

This comment has been minimized.

Copy link
@naqvis

naqvis Sep 16, 2019

Author Owner

😄 I didn't implement XMPP library before, but have been doing software development since last 20 years 😆 . I spent few nights on implementing the auth code. I do OSS as my way to give-back to community with hope some of my contribution might be helpful to someone.

This comment has been minimized.

Copy link
@alexanderadam

alexanderadam Sep 16, 2019

Awesome, you are an absolutely impressive person!

else
raise "Server didn't sent salt"
end
if (r = res["r"]?)
raise "Server sent nonce didn't match" unless r.starts_with?(nonce)
else
raise "Server didn't sent nonce"
end
res
end

private def xor(a : Bytes, b : Bytes)
if a.bytesize > b.bytesize
b.map_with_index { |v, i| v ^ a[i] }
else
a.map_with_index { |v, i| v ^ b[i] }
end
end

private def escape(str : String)
# Escape "=" and ","
str.gsub("=", "=3D").gsub(",", "=2C")
end
end
end
Loading

0 comments on commit c7ceb33

Please sign in to comment.