2010/12/20

RubyによるJabberクライアントの作成とパスワードの管理

普通のJabberクライアントをRubyで作成するためには、 xmpp4rライブラリを使って数行のコードで完結します。

今回は外部のサーバにコードを配置するので、少し要件を加える事にしました。

  • プログラマとパスワードを管理する人間は別であるという前提が存在する
  • JabberIDのパスワードはスクリプトに埋め込まない
  • パスワードが書かれたファイル単体が流出しても問題ないよう暗号化する
  • 各ファイルは別の名前に変更して使う事ができるようにする

さらにTest::Unitフレームワークでテストができるように、コードの共通部分はモジュール(module YaJabber)の中に埋め込んでいます。

ファイルの構造

今回はRuby 1.9.2で標準ライブラリに加わっている点を考えて、ファイルフォーマットにJSONを使用しました。

ディレクトリ構造は以下のとおりで、各ファイルへは相対パスでアクセスするようになっています。


  ./sendmsg2j.rb            ## コマンド本体
  conf/sec_config.json      ## デフォルトの共通鍵ファイル
  conf/config.json          ## デフォルトの設定ファイル
  conf/gmail_config.json    ## Gmail接続用見本
  conf/jabber_config.json   ## Jabber.org接続用見本
  lib/xmpp4r                ## jabberクライアント用ライブラリ
共通鍵ファイルの形式

暗号化に使用する共通鍵データファイル: sec_config.json

{
  "sec_text" : "5ee6f7e62b8b1de083bb2617738a4ade620f9c09"
}
接続用ID設定ファイルの形式

パスワードやJabberサーバへの接続情報は別ファイルとして扱っています

Jabberクライアントの接続情報 Gmail版: gmail_config.json

{
  "user_name":"xxxxx@gmail.com/Batch",
  "user_pass":"357150946dd3950f96af22f2f639bc4d",
  "user_salt":"4e6c13d4aac544b2",
  "server_name":"talk.google.com",
  "server_port":"5222",
  "remote_user":"xxxxx@gmail.com/Home",
  "msg_subject":"Post message from VPS"
}

Jabberクライアントの接続情報 Jabber.org版: jabber_config.json

{
  "user_name":"xxxxx@jabber.org/Batch",
  "user_pass":"fcf53684962bc19f273e5a7c9bad257f",
  "user_salt":"bf6123bf1e2c52b6",
  "server_name":"jabber.org",
  "server_port":"5222",
  "remote_user":"xxxxx@jabber.org/Home",
  "msg_subject":"Post message from VPS"
}

この2つの設定ファイルを送信したいIDによってコマンドオプションで設定ファイルを切り替えるか、デフォルトの設定ファイル config.json を上書きして使っています。

想定する使い方

初期化時のシナリオ

sec_config.jsonファイルの"sec_text" : の右辺を任意の文字列で上書きする。

{ "sec_text" :   "この中を任意の文字列で書き換える" }
パスワード設定/変更時のシナリオ
  • 新しいパスワードを準備する
  • ./sendmsg2j.rb -eを実行する
  • パスワードを入力し、出力された2行をconfig.jsonファイルの内容と差し替える
  • テスト用メッセージを送信する
メッセージの送信のシナリオ

送りたいメッセージは標準入力から読み込むので、コマンドの出力などをそのまま送信できます。

echo "message" | ./sendmsg2j.rb

外部ファイルからメッセージを読み込む場合はリダイレクトで行ないます。

./sendmsg2j.rb < message_file.txt
設定ファイルの変更方法

'-c'オプションを使って、別のID設定情報を使う。

echo "message" | ./sendmsg2j.rb -c conf/gmail_config.json
共通鍵ファイル名を変更したい

'-s'オプションを使って、別の共有鍵データファイルを使う。

echo "message" | ./sendmsg2j.rb -c conf/gmail_config.json

まとめ

Jabberクライアント自体はちゃんと動いているのと、オンラインでいるユーザにメッセージを送信する手段としてはおもしろいと思います。

重要な情報であればメールと組み合せれば、メールサーバの滞留などに対する回避策にもなるでしょう。

問題だと感じたのは、設定ファイルの形式です。 けれど、これは別の話なので記事を分けました。

Appendix. スクリプト本体

いろいろコードをいじったので無駄に焼け太った感じがあります。

module内部のメソッドは関数型言語のようなイメージですが、綺麗に切り取られた印象もなく、かなりごちゃごちゃしています。

パスワードファイルとの分離のためにこうなってしまっただけで、xmpp4rの使い方としては特別な事はしていないのでご注意ください。

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
#
#= Yadiary Jabber Client Script
#
# This script assumes the following directory structures.
#
#   ./sendmsg2j.rb          ## main and module file
#   ./conf/sec_config.json  ## the '-s' option changes the file name.
#   ./conf/config.json      ## the '-c' option changes the file name.
#
# This is tested with talk.google.org and jabber.org servers.
#
#== Encryption mode
#
# Your jabber password should be encrypted this script at first.
#
#   ./sendmsg2j.rb -e [-s <sec_config.json>]
#
# After enter your password, then the script shows you 'user_pass' and 'user_salt' parameters.
# These parameters are copied to your 'config.json' file.
# The data structure of the 'config.json' file will be described later.
#
#== Send message mode
# The message body is read by the standard input stream by following command line.
#   ./sendmsg2j.rb [-s <sec_config.json>] [-c <config.json>]
#
# In practical use, you will use this script with following idioms.
#
# 1. ./sendmsg2j.rb [-s <sec_config.json>] [-c <config.json>] < message_file
# 2. echo message | ./sendmsg2j.rb [-s <sec_config.json>] [-c <config.json>]
#
# First case (#1), the 'message_file' contains the entire message body.
# Last case (#2), the 'message' string is your choice.
#
#== Unit Tests
# The 'unittest/test_sendmsg2j.rb' is the executable Test::Unit script file.
# It will test all methods except
#
#== 'sec_config.json file
# The 'sec_config.json' file contains the essential password string.
# It should have the following data structure.
#
#   { "sec_text" : ".. password string like sha1sum hash value ..." }
#
# You can change the filename and location by the '-s' option.
#
#== 'config.json file
# The 'config.json' file is the following data structure.
#   {
#     "user_name":"daemon@example.org/Batch",
#     "user_pass":"5208502be38983bbf6847c0f7554f7f7",
#     "user_salt":"63c9240d88705faf",
#     "server_name":"example.org",
#     "server_port":"5222",
#     "remote_user":"user01@example.org/Home",
#     "msg_subject":"post message from daemon@example.org/Batch"
#   }
# You can change the filename and location by the '-c' option.
#
$:.unshift File.dirname($0)
$:.unshift File.join([File.dirname($0),"lib"])
require 'optparse'
require 'xmpp4r'
require 'json'

# 
# This module is for Yadiary Jabber Client.
#
# These methods should be tested by the unittest/test_sendmsg2j.rb script.
module YaJabber
  
  # It should have the following data structure.
  #
  #   { "sec_text" : ".. password string like sha1sum hash value ..." }
  #
  SEC_CONFIG_FILE = File.join([File.dirname($0), "conf", "sec_config.json"])
  
  # The 'config.json' file is the following data structure.
  #   {
  #     "user_name":"daemon@example.org/Batch",
  #     "user_pass":"5208502be38983bbf6847c0f7554f7f7",
  #     "user_salt":"63c9240d88705faf",
  #     "server_name":"example.org",
  #     "server_port":"5222",
  #     "remote_user":"user01@example.org/Home",
  #     "msg_subject":"post message from daemon@example.org/Batch"
  #   }
  #
  CONFIG_FILE = File.join([File.dirname($0), "conf", "config.json"])
  
  # A wrapper method to read json file.
  #
  # Example:
  #   conf = json_read(SEC_CONFIG_FILE)
  #
  def json_read(file)
    conf = nil
    begin
      conf = JSON.parse(open(file).read)
    rescue
      conf = nil
    end
    return conf
  end
  
  #
  # Get your password string from the given json file.
  #
  # Example:
  #   pass = get_password(sec_conf_file)
  #
  def get_password(sec_conf_file)  # sec_conf_file - test
    conf = json_read(sec_conf_file)
    res = ""
    label = 'sec_text'
    res = conf[label] if conf.kind_of?(Hash) and conf.has_key?(label)
    return res
  end

  # 
  # Example:
  #   plain_text = STDIN.gets.strip
  #   enc_text,salt = encrypt(pass, plain_text)
  #
  def encrypt(pass, plain_text)
    begin
      salt = OpenSSL::Random.random_bytes(8)
      enc = OpenSSL::Cipher::Cipher.new('aes-256-cbc')
      enc.encrypt
      enc.pkcs5_keyivgen(pass.to_s, salt.to_s, 1)
      e_text = enc.update(plain_text) + enc.final
      return e_text.unpack("H*").join, salt.unpack("H*").join
    rescue
      p $!
      return "",""
    end
  end
  #
  # Example:
  #   user_rawpass = decrypt(pass, enc_text, salt)
  #
  def decrypt(pass, text, salt)
    begin
      n_salt = [salt].pack("H*")
      n_text = [text].pack("H*")
      enc = OpenSSL::Cipher::Cipher.new('aes-256-cbc')
      enc.decrypt
      enc.pkcs5_keyivgen(pass.to_s, n_salt.to_s, 1)
      return enc.update(n_text) + enc.final
    rescue
      p $!
      return ""
    end
  end
  
  #
  # It returns the existing filename.
  #
  # Example:
  #   begin
  #     @sec_conf_file = check_file(@option['sec_conf_file'], SEC_CONFIG_FILE)
  #   rescue
  #     printf("[error] security conf file '%s' not found.\n", @sec_conf_file)
  #     exit(1)
  #   end
  #
  def check_file(candidate_file=nil, default_file=nil)
    conf_file = candidate_file
    conf_file = default_file if conf_file == nil or not FileTest.exist?(conf_file)
    raise if not FileTest.exist?(conf_file)
    return conf_file
  end

  #
  # This is a domain specific method.
  # The 'conf' argument must be the CONFIG_FILE format.
  #
  def check_conf(conf={})
    ## remove empty value
    conf.reject! { |k,v|
      true if v == nil or v == ""
    }
    ## set the default value for optional parameters
    conf['server_name'] = "jabber.org" if not conf.has_key?('server_name')
    conf['server_port'] = "5222" if not conf.has_key?('server_port')
    conf['msg_subject'] = format("Messages from %s", conf['user_name']) if not conf.has_key?('msg_subject')
    ## check the config key/value pairs.
    if not conf.has_key?('user_name') or not conf.has_key?('user_pass') or 
        not conf.has_key?('user_salt') or not conf.has_key?('remote_user')
      return false ## means BAD
    end
    return true ## means GOOD
  end
  
  # parse command line options
  def option_parser
    res = { 
      "encrypt_mode" => false,
      "conf_file" => nil,
      "sec_conf_file" => nil
    }
    OptionParser.new do |opts|
      opts.banner = 'Usage: ' + File.basename($0) + '\tinput: read messages from stdin.'
      opts.separator ''
      opts.on('-e', '--encrypt', 'Interactive password encrypt mode') {
        res['encrypt_mode'] = true
      }
      opts.on('-c', '--config filename', 'Set a config file') do |c|
        res['conf_file'] = c if FileTest.exist?(c)
      end
      opts.on('-s', '--sec_config filename', 'Set a security config file') do |c|
        res['sec_conf_file'] = c if FileTest.exist?(c)
      end
      opts.on_tail('-h', '--help', 'Show this message') {
        puts opts
        exit
      }
      opts.parse!(ARGV)
    end
    return res
  end

  # aggreate method which will send a message.
  def send_message(user_name, user_pass, remote_user,msg_subject, server_name, server_port)
    client = Jabber::Client.new(Jabber::JID.new(user_name))
    client.connect(server_name, server_port)
    client.auth(user_pass)
    body = STDIN.readlines.join
    m = Jabber::Message.new(remote_user, body).set_type(:normal).set_id('1').set_subject(msg_subject)
    client.send(m)
    client.close
  end
end

##########
## main ##
##########
if $0 == __FILE__
  include YaJabber

  ###################################
  ## load encrypt/decrypt password ##
  ###################################
  @option = option_parser
  begin
    @sec_conf_file = check_file(@option['sec_conf_file'], SEC_CONFIG_FILE)
  rescue
    printf("[error] security conf file '%s' not found.\n", @sec_conf_file)
    exit(1)
  end
  @crypt_pass = get_password(@sec_conf_file)
  if @crypt_pass.empty?
    printf("[error] Your security config file, %s, doesn't have the 'sec_text' parameter or non-empty value.\n", @sec_conf_file)
    printf("  Please check your security config file.\n")
    exit(1)
  end
  
  #####################
  ## encryption mode ##
  #####################
  if @option['encrypt_mode']
    ## confirm the input
    print "** password encryption mode **\n"
    print "enter string: "
    line = STDIN.gets.strip
    if line.empty?
      print "Please enter the non-empty string as your password.\n"
      exit
    end

    ## ok, let's start.
    enc_text,salt = encrypt(@crypt_pass, line)
    if enc_text.empty? or salt.empty?
      printf(<<-EOF, @sec_conf_file)
  Please check your "sec_text" parameter in the security config json file, %s.
EOF
    else
      printf(<<-EOF, enc_text, salt)
  "user_pass":"%s",
  "user_salt":"%s",
exit.
EOF
    end
    exit
  end
  
  #################
  ## normal mode ##
  #################
  ## load config file
  begin
    @conf_file = check_file(@option['conf_file'], CONFIG_FILE)
  rescue
    printf("[error] conf file, %s, not found.\n", @conf_file)
    exit(1)
  end
  conf = json_read(@conf_file)
  
  if not check_conf(conf)
    printf(<<-EOF, @conf_file)
[error] Config key/value pairs are not enough to start.
The config file %s should have like following values.
{
  "user_name":"sysuser01@jabber.org/Batch",
  "user_pass":"19570e028a958d0cbc91add9c1516e05",
  "user_salt":"e42489150b1bed52",
  "remote_user":"user01@jabber.org/Home",
  "server_name":"jabber.org (option)",
  "server_port":"5222 (option)",
  "msg_subject":"System message from sysuser01@jabber.org/Batch. (option)"
}
EOF
    exit(1)
  end
  
  ##################
  ## send message ##
  ##################
  user_pass = decrypt(@crypt_pass, conf['user_pass'], conf['user_salt'])
  if user_pass.empty?
    printf("[error] Please check 'user_pass' or 'user_salt' fields on config file, %s\n", @conf_file)
    exit(1)
  end
  send_message(conf['user_name'], user_pass, conf['remote_user'],
               conf['msg_subject'], conf['server_name'], conf['server_port'])
end
#################
## end of main ##
#################

0 件のコメント: