From c4c947982c9ffdc15ed0e0203c74ba8ec66e8135 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Fri, 8 Nov 2013 11:11:54 -0700 Subject: [PATCH] SASL authentication --- lib/tkellem/bouncer_connection.rb | 117 +++++++++++++++++++++++++----- lib/tkellem/sasl/base.rb | 9 +++ lib/tkellem/sasl/dh.rb | 39 ++++++++++ lib/tkellem/sasl/dh_aes.rb | 24 ++++++ lib/tkellem/sasl/dh_blowfish.rb | 20 +++++ lib/tkellem/sasl/plain.rb | 22 ++++++ 6 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 lib/tkellem/sasl/base.rb create mode 100644 lib/tkellem/sasl/dh.rb create mode 100644 lib/tkellem/sasl/dh_aes.rb create mode 100644 lib/tkellem/sasl/dh_blowfish.rb create mode 100644 lib/tkellem/sasl/plain.rb diff --git a/lib/tkellem/bouncer_connection.rb b/lib/tkellem/bouncer_connection.rb index f25f2e5..cf86b66 100644 --- a/lib/tkellem/bouncer_connection.rb +++ b/lib/tkellem/bouncer_connection.rb @@ -1,8 +1,12 @@ # encoding: utf-8 require 'active_support/core_ext/object/blank' +require 'base64' require 'eventmachine' require 'tkellem/irc_message' +require 'tkellem/sasl/plain' +require 'tkellem/sasl/dh_aes' +require 'tkellem/sasl/dh_blowfish' module Tkellem @@ -133,6 +137,38 @@ def self.register_tag_cap(*caps) end register_cap 'tls' + register_cap 'sasl' + + def self.sasl_mechanisms + @sasl_mechanisms ||= {} + end + def self.register_sasl(mechanism, klass) + sasl_mechanisms[mechanism] = klass + end + + module TkellemSaslAuthenticate + def authenticate + return false unless authcid + username, _, _ = BouncerConnection.parse_username(authcid) + + !!User.authenticate(username, passwd) + end + end + + class TkellemPlainSasl < SASL::Plain + include TkellemSaslAuthenticate + end + register_sasl('PLAIN', TkellemPlainSasl) + + class TkellemDhBlowfishSasl < SASL::DhBlowfish + include TkellemSaslAuthenticate + end + register_sasl('DH-BLOWFISH', TkellemDhBlowfishSasl) + + class TkellemDhAesSasl < SASL::DhAes + include TkellemSaslAuthenticate + end + register_sasl('DH-AES', TkellemDhAesSasl) def receive_line(line) failsafe("message: {#{line}}") do @@ -157,7 +193,7 @@ def receive_line(line) elsif command == 'CAP' case msg.args.first when 'LS' - send_msg("CAP #{nick} LS :#{BouncerConnection.caps.to_a.join(' ')}") + send_msg(":tkellem CAP #{nick} LS :#{BouncerConnection.caps.to_a.join(' ')}") when 'REQ' reqs = msg.args.last.split(' ') adds = []; removes = [] @@ -169,18 +205,18 @@ def receive_line(line) end end if !(adds - BouncerConnection.caps.to_a).empty? || !(removes - BouncerConnection.caps.to_a).empty? - send_msg("CAP #{nick} NAK :#{msg.args.last}") + send_msg(":tkellem CAP #{nick} NAK :#{msg.args.last}") else @caps += adds @caps -= removes @tags = !(@caps & BouncerConnection.tag_caps).empty? - send_msg("CAP #{nick} ACK :#{msg.args.last}") + send_msg(":tkellem CAP #{nick} ACK :#{msg.args.last}") end when 'LIST' - send_msg("CAP #{nick} LIST :#{caps.to_a.join(' ')}") + send_msg(":tkellem CAP #{nick} LIST :#{caps.to_a.join(' ')}") when 'CLEAR' @tags = false - send_msg("CAP #{nick} ACK :#{caps.map { |cap| "-#{cap}" }.join(' ') }") + send_msg(":tkellem CAP #{nick} ACK :#{caps.map { |cap| "-#{cap}" }.join(' ') }") when 'END' # do nothing else @@ -188,6 +224,46 @@ def receive_line(line) end elsif command == 'PASS' && @state == :auth @password = msg.args.first + elsif command == 'AUTHENTICATE' && @state == :auth && caps.include?('sasl') + if msg.args.first == '*' + @sasl = nil + return send_msg(":tkellem 906 :SASL authentication aborted") + end + if !@sasl + mechanism = msg.args.first + if !BouncerConnection.sasl_mechanisms[mechanism] + return send_msg(":tkellem 904 #{nick} :SASL mechanism not supported") + end + @sasl = BouncerConnection.sasl_mechanisms[mechanism].new + response = nil + else + @sasl_response += msg.args.last + return if msg.args.last.length == 400 + response = Base64.decode64(@sasl_response) + end + challenge = @sasl.response(response) + if challenge + @sasl_response = '' + challenge = Base64.strict_encode64(challenge) + while challenge.length >= 400 + send_msg("AUTHENTICATE #{challenge[0..400]}") + challenge.slice!(0...400) + end + challenge = '+' if challenge.empty? + send_msg("AUTHENTICATE #{challenge}") + else + @username, @conn_name, @device_name = BouncerConnection.parse_username(@sasl.authcid) if @sasl.authcid + if @sasl.authcid && @user = User.where(username: @username).first + send_msg(":tkellem 900 #{nick} :You are now logged in") + send_msg(":tkellem 903 #{nick} :SASL authentication successful") + maybe_connect + else + send_msg(":tkellem 904 #{nick} :SASL authentication failed") + end + @sasl = nil + end + elsif command == 'AUTHENTICATE' && @state != :auth && caps.include?('sasl') + send_msg(":tkellem 907 :Already authenticated") elsif command == 'NICK' && @state == :auth @connecting_nick = msg.args.first maybe_connect @@ -195,14 +271,14 @@ def receive_line(line) close_connection elsif command == 'USER' && @state == :auth unless @username - @username, @conn_info = msg.args.first.strip.split('@', 2).map { |a| a.downcase } + @username, @conn_name, @device_name = BouncerConnection.parse_username(msg.args.first) end maybe_connect elsif command == 'STARTTLS' && !@ssl send_msg("670 :STARTTLS successful, go ahead with TLS handshake") start_tls elsif command == 'PING' && @state == :auth - send_msg("PONG #{args.first}") + send_msg("PONG #{msg.args.first}") elsif @state == :auth error!("Protocol error. You must authenticate first.") elsif @state == :connected @@ -213,20 +289,21 @@ def receive_line(line) end end + def self.parse_username(username) + username, conn_info = username.downcase.split('@', 2) + conn_name, device_name = conn_info.split(':', 2) if conn_info + device_name ||= 'default' + [username, conn_name, device_name] + end + def maybe_connect - return unless @connecting_nick && @username && !@user - if @password - @name = @username - @user = User.authenticate(@username, @password) + return unless @connecting_nick && @username + if @password || @user + @user ||= User.authenticate(@username, @password) return error!("Unknown username: #{@username} or bad password.") unless @user - if @conn_info && !@conn_info.empty? - @conn_name, @device_name = @conn_info.split(':', 2) - # 'default' or missing device_name to use the default backlog - # pass a device_name to have device-independent backlogs - @device_name = @device_name.presence || 'default' - @name = "#{@username}-#{@conn_name}" - @name += "-#{@device_name}" if @device_name + if @conn_name + @name = "#{@username}-#{@conn_name}-#{device_name}" connect_to_irc_server else @name = "#{@username}-console" @@ -235,7 +312,9 @@ def maybe_connect else user = User.where(username: @username).first if user || user_registration == 'closed' - error!("No password given. Make sure to set your password in your IRC client config, and connect again.") + # wait longer for a SASL password + return if caps.include?('sasl') + #error!("No password given. Make sure to set your password in your IRC client config, and connect again.") if user_registration != 'closed' error!("If you are trying to register for a new account, this username is already taken. Please select another.") end diff --git a/lib/tkellem/sasl/base.rb b/lib/tkellem/sasl/base.rb new file mode 100644 index 0000000..044be69 --- /dev/null +++ b/lib/tkellem/sasl/base.rb @@ -0,0 +1,9 @@ +module Tkellem +module SASL + +class Base + attr_reader :authzid, :authcid +end + +end +end \ No newline at end of file diff --git a/lib/tkellem/sasl/dh.rb b/lib/tkellem/sasl/dh.rb new file mode 100644 index 0000000..7694c58 --- /dev/null +++ b/lib/tkellem/sasl/dh.rb @@ -0,0 +1,39 @@ +require 'tkellem/bouncer_connection' +require 'tkellem/sasl/base' + +module Tkellem + module SASL + +# Should inherit and implement authorize + class DH < Base + attr_reader :passwd + + def self.dh + @dh ||= OpenSSL::PKey::DH.generate(256, 5) + end + + def response(response) + if !response + @dh = OpenSSL::PKey::DH.new(DH.dh.to_der) + @dh.generate_key! + p, g, y = @dh.p.to_s(2), @dh.g.to_s(2), @dh.pub_key.to_s(2) + [p.bytesize, p, g.bytesize, g, y.bytesize, y].pack('na*na*na*') + elsif !@dh + # never sent a challenge? + nil + else + pub_key_len = response.slice!(0...2).unpack('n').first + pub_key = response.slice!(0...pub_key_len) + sym_key = @dh.compute_key(OpenSSL::BN.new(pub_key, 2)) + decrypt(response, sym_key) + unless authenticate + @authcid, @passwd = nil + end + + nil + end + end + end + + end +end \ No newline at end of file diff --git a/lib/tkellem/sasl/dh_aes.rb b/lib/tkellem/sasl/dh_aes.rb new file mode 100644 index 0000000..0e66a1c --- /dev/null +++ b/lib/tkellem/sasl/dh_aes.rb @@ -0,0 +1,24 @@ +require 'tkellem/bouncer_connection' +require 'tkellem/sasl/dh' + +module Tkellem +module SASL + +# Should inherit and implement authorize +class DhAes < DH + def decrypt(response, sym_key) + iv, crypted = response.unpack("a16a*") + cipher = OpenSSL::Cipher.new("AES-#{sym_key.length * 8}-CBC") + cipher.key_len = sym_key.length + cipher.decrypt + cipher.key = sym_key + cipher.iv = iv + plain = cipher.update(crypted) + # need to get the rest out of the buffer, but can't call final cause of non-standard padding + plain += cipher.update('garbage') + @authcid, @passwd = plain.unpack("Z*Z*") + end +end + +end +end diff --git a/lib/tkellem/sasl/dh_blowfish.rb b/lib/tkellem/sasl/dh_blowfish.rb new file mode 100644 index 0000000..82c6dfd --- /dev/null +++ b/lib/tkellem/sasl/dh_blowfish.rb @@ -0,0 +1,20 @@ +require 'tkellem/bouncer_connection' +require 'tkellem/sasl/dh' + +module Tkellem +module SASL + +# Should inherit and implement authorize +class DhBlowfish < DH + def decrypt(response, sym_key) + @authcid, crypted_passwd = response.unpack("Z*a*") + cipher = OpenSSL::Cipher.new("BF-ECB") + cipher.key_len = sym_key.length + cipher.decrypt + cipher.key = sym_key + @passwd = cipher.update(crypted_passwd) + end +end + +end +end \ No newline at end of file diff --git a/lib/tkellem/sasl/plain.rb b/lib/tkellem/sasl/plain.rb new file mode 100644 index 0000000..fd2526b --- /dev/null +++ b/lib/tkellem/sasl/plain.rb @@ -0,0 +1,22 @@ +require 'tkellem/bouncer_connection' +require 'tkellem/sasl/base' + +module Tkellem +module SASL + +# Should inherit and implement authorize +class Plain < Base + attr_reader :passwd + + def response(response) + return '' unless response + @authzid, @authcid, @passwd = response.split("\0", 3) + unless authenticate + @authzid, @authcid, @passwd = nil + end + nil + end +end + +end +end \ No newline at end of file