diff --git a/.gitignore b/.gitignore index f74e8fc..b25c15b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ *~ -config.rb diff --git a/bot.rb b/bot.rb index 1d5c0f6..c6d96ce 100644 --- a/bot.rb +++ b/bot.rb @@ -1,73 +1,53 @@ require 'xmpp4r' -require 'xmpp4r/muc/helper/simplemucclient' +require 'xmpp4r/muc/helper/mucclient' + +require_relative 'sasl' require_relative 'config' module Lain - Version = '0.1' - class Bot + Version = '0.1' def initialize - $stderr.puts "Lain #{Version}" - - @modules = {} - - $stderr.print 'Loading modules...' - - Config::Modules.each { |mod, cfg| - $stderr.print " #{mod}" - require_relative "modules/#{mod}" - @modules[mod] = Modules.const_get(mod).new(self, cfg) - } - - $stderr.puts '.' - - @commands = @modules.values.reduce({}) { |c, mod| c.merge mod.commands }.to_a.sort - - $stderr.print 'Connecting... ' + $stderr.puts "Lain #{Version} starting..." @cl = Jabber::Client.new(Jabber::JID.new(Config::JID)) @cl.connect @cl.auth(Config::Password) @cl.send(Jabber::Presence.new) - $stderr.puts 'connection established.' + @modules = {} + + $stderr.puts 'Loading modules...' + + Config::Modules.each { |mod| + require_relative "modules/#{mod}" + @modules[mod] = Modules.const_get(mod).new self + } @mucs = {} Config::Rooms.each { |r| - muc = Jabber::MUC::SimpleMUCClient.new(@cl) + muc = Jabber::MUC::MUCClient.new(@cl) @mucs[r] = muc muc.add_message_callback { |msg| - unless msg.from == r - @modules.each { | _, mod | - begin - mod.on_message muc, msg - rescue - end - } - end + @modules.each { | _, mod | + begin + mod.on_message muc, msg + rescue + end + } } - $stderr.print "Trying to access room `#{r}'... " + $stderr.puts "Joining room `#{r}'..." - muc.join(r) - - begin - muc.configure - rescue - end - - $stderr.puts "access granted." + muc.join("#{r}/#{Config::Nick}") + muc.configure() } - $stderr.puts 'Initialization finished.' - end - - def commands - @commands + $stderr.puts 'Startup finished.' end def run diff --git a/config.rb.example b/config.rb.example deleted file mode 100644 index 00242b3..0000000 --- a/config.rb.example +++ /dev/null @@ -1,16 +0,0 @@ -module Lain - module Config - JID = '' - Password = '' - - Rooms = [''] - - Modules = { - :Credits => {}, - :DDate => {}, - :Fortune => { :command => '/usr/bin/fortune -a' }, - :Help => {}, - :Topic => {}, - } - end -end diff --git a/lain.rb b/lain.rb old mode 100755 new mode 100644 diff --git a/module_base.rb b/module_base.rb index b209e7b..500fd20 100644 --- a/module_base.rb +++ b/module_base.rb @@ -1,17 +1,11 @@ module Lain module Modules class Base - def initialize(lain, config) - @lain = lain - @config = config + def initialize(lain) end def on_message(muc, message) end - - def commands - {} - end end end end diff --git a/modules/Credits.rb b/modules/Credits.rb deleted file mode 100644 index 1df7d18..0000000 --- a/modules/Credits.rb +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -require 'xmpp4r/message' - -require_relative '../module_base' - -module Lain - module Modules - class Credits < Base - CreditText = < 'show credits' - } - end - end - end -end diff --git a/modules/DDate.rb b/modules/DDate.rb index 5f8634e..1402648 100644 --- a/modules/DDate.rb +++ b/modules/DDate.rb @@ -6,15 +6,13 @@ module Lain module Modules class DDate < Base def on_message(muc, message) - return unless /\A!ddate\b/ =~ message.body + return unless message.type == :groupchat + return unless /!ddate\b/ =~ message.body - muc.say("DISCORDIAN DATE " + IO.popen("ddate").read.chomp) - end - - def commands - { - '!ddate' => 'show Discordian date' - } + p = IO.popen("ddate") + while (line = p.gets) + muc.send(Jabber::Message.new(message.to, line.chomp)) + end end end end diff --git a/modules/Fortune.rb b/modules/Fortune.rb deleted file mode 100644 index 2d4436b..0000000 --- a/modules/Fortune.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'xmpp4r/message' - -require_relative '../module_base' - -module Lain - module Modules - class Fortune < Base - def on_message(muc, message) - return unless /\A!fortune\b/ =~ message.body - - muc.say("\nBEGIN FORTUNE COOKIE\n" + IO.popen(@config[:command]).read.chomp + "\nEND FORTUNE COOKIE") - end - - def commands - { - '!fortune' => 'fortune cookies' - } - end - end - end -end diff --git a/modules/Help.rb b/modules/Help.rb deleted file mode 100644 index d4a6d1f..0000000 --- a/modules/Help.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'xmpp4r/message' - -require_relative '../module_base' - -module Lain - module Modules - class Help < Base - def on_message(muc, message) - return unless /\A!help\b/ =~ message.body - - muc.say("\nBEGIN COMMAND LIST" + @lain.commands.reduce('') { |s, cmd| s + "\n#{cmd[0]}: #{cmd[1]}" } + "\nEND COMMAND LIST") - end - - def commands - { - '!help' => 'show this help' - } - end - end - end -end diff --git a/modules/Topic.rb b/modules/Topic.rb deleted file mode 100644 index fa2291a..0000000 --- a/modules/Topic.rb +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -require 'xmpp4r/message' - -require_relative '../module_base' - -module Lain - module Modules - class Topic < Base - def on_message(muc, message) - return unless /\A!topic\b/ =~ message.body - - if /\A!topic\s*(?:\ss(?:how)?.*)?\Z/ =~ message.body - show_topic(muc, message) - elsif /\A!topic\s+a(?:dd)?\s+(.+)\Z/ =~ message.body - add_topic(muc, message, $~[1].strip) - elsif /\A!topic\s+d(?:el(?:ete)?)?\s+([[:xdigit:]]+)\Z/ =~ message.body - del_topic(muc, message, $~[1].to_i(16)) - end - end - - def commands - { - '!topic [s[how]]' => 'show topics (with indices)', - '!topic a[dd] ' => 'add a topic', - '!topic d[el[ete]] ' => 'remove topic ' - } - end - - private - - def current_topic(muc) - topic = muc.subject - return [] if topic.nil? - - topic.split('|').map { |s| s.strip } - end - - def set_topic(muc, topic) - muc.subject = topic.join(" | ") - end - - def topic_list(topic) - topic.each_with_index.map { |t, i| "[#{'%02x' % i}] #{t}" }.join("\n") - end - - def show_topic(muc, message) - topic = current_topic muc - if topic.empty? - muc.say "\nTOPIC EMPTY" - return - end - - muc.say("\nBEGIN TOPIC LIST\n" + topic_list(topic) + "\nEND TOPIC LIST") - end - - def add_topic(muc, message, text) - topic = current_topic muc - if text.empty? - pre = "\nERROR: No topic given." - else - topic.unshift text - set_topic(muc, topic) - pre = "\nSUCCESS" - end - - muc.say(pre + "\n\nBEGIN TOPIC LIST\n" + topic_list(topic) + "\nEND TOPIC LIST") - end - - def del_topic(muc, message, index) - topic = current_topic muc - begin - deleted = topic.delete_at index - rescue - deleted = nil - end - if deleted.nil? - pre = "\nERROR: Invalid index [#{'%02x' % index}]." - else - set_topic(muc, topic) - pre = "\nSUCCESS" - end - - muc.say(pre + "\n\nBEGIN TOPIC LIST\n" + topic_list(topic) + "\nEND TOPIC LIST") - end - end - end -end diff --git a/sasl.rb b/sasl.rb new file mode 100644 index 0000000..f871533 --- /dev/null +++ b/sasl.rb @@ -0,0 +1,201 @@ +require 'digest/sha1' +require 'openssl' +require 'xmpp4r/client' +require 'xmpp4r/sasl' + + +module Jabber + class Client + def auth(password) + #begin + if @stream_mechanisms.include? 'SCRAM-SHA-1' + auth_sasl SASL.new(self, 'SCRAM-SHA-1'), password + elsif @stream_mechanisms.include? 'DIGEST-MD5' + auth_sasl SASL.new(self, 'DIGEST-MD5'), password + elsif @stream_mechanisms.include? 'PLAIN' + auth_sasl SASL.new(self, 'PLAIN'), password + else + auth_nonsasl(password) + end + #rescue + # Jabber::debuglog("#{$!.class}: #{$!}\n#{$!.backtrace.join("\n")}") + # raise ClientAuthenticationFailure.new, $!.to_s + #end + end + end + + module SASL + ## + # Factory function to obtain a SASL helper for the specified mechanism + def SASL.new(stream, mechanism) + case mechanism + when 'SCRAM-SHA-1' + SCRAMSHA1.new(stream) + 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 SCRAM-SHA1 authentication helper + class SCRAMSHA1 < Base + ## + # Sends the wished auth mechanism and wait for a challenge + # + # (proceed with SCRAMSHA1#auth) + def initialize(stream) + super + + @nonce = generate_nonce + @client_fm = "n=#{escape @stream.jid.node },r=#{@nonce}" + + challenge = {} + challenge_text = '' + error = nil + @stream.send(generate_auth('SCRAM-SHA-1', text=Base64::strict_encode64('n,,'+@client_fm))) { |reply| + if reply.name == 'challenge' and reply.namespace == NS_SASL + challenge_text = Base64::decode64(reply.text) + challenge = decode_challenge(challenge_text) + else + error = reply.first_element(nil).name + end + true + } + raise error if error + + @server_fm = challenge_text + @cnonce = challenge['r'] + @salt = Base64::decode64(challenge['s']) + @iterations = challenge['i'].to_i + + raise 'SCRAM-SHA-1 protocol error' if @cnonce[0, @nonce.length] != @nonce + end + + def decode_challenge(text) + 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 SCRAM-SHA-1 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) + salted_password = hi(password, @salt, @iterations) + client_key = hmac(salted_password, 'Client Key') + stored_key = h(client_key) + + final_message = "c=#{Base64::strict_encode64('n,,')},r=#{@cnonce}" + auth_message = "#{@client_fm},#{@server_fm},#{final_message}" + + client_signature = hmac(stored_key, auth_message) + client_proof = xor(client_key, client_signature) + + + response_text = "#{final_message},p=#{Base64::strict_encode64(client_proof)}" + + Jabber::debuglog("SASL SCRAM-SHA-1 response:\n#{response_text}") + + r = REXML::Element.new('response') + r.add_namespace NS_SASL + r.text = Base64::strict_encode64(response_text) + + error = nil + success = {} + @stream.send(r) { |reply| + if reply.name == 'success' and reply.namespace == NS_SASL + success = decode_challenge(Base64::decode64(reply.text)) + elsif reply.name != 'challenge' + error = reply.first_element(nil).name + end + true + } + + raise error if error + + server_key = hmac(salted_password, 'Server Key') + server_signature = hmac(server_key, auth_message) + + raise "Server authentication failed" if Base64::decode64(success['v']) != server_signature + end + + private + + def xor(a, b) + a.unpack('C*').zip(b.unpack('C*')).collect { | x, y | x ^ y }.pack('C*') + end + + def h(s) + Digest::SHA1.digest(s) + end + + def hmac(key, s) + OpenSSL::HMAC.digest('sha1', key, s) + end + + def hi(s, salt, i) + r = Array.new(size=20, obj=0).pack('C*') + u = salt + [0, 0, 0, 1].pack('C*') + + i.times do |x| + u = hmac(s, u) + r = xor(r, u) + end + + r + end + + def escape(data) + data.gsub(/=/, '=3D').gsub(/,/, '=2C') + end + end + end +end