RabbitMQ あれこれ

Message Queue を使う必要が出てきました。以前、Twitter 社の Kestrel を使ったことがあったのですが最近見てみると開発も活発でなく前途不安です。2年程度放置されている感じです。Redis の List を使うことで Message Queueは可能なのですが、クライアントライブラリが悪いのかエラーが出て、エラーを無理やりハンドリングするとワーカーのメモリがどんどん増えていくので辛い感じでした。さらにCPU負荷も高いようですし。というわけでいろいろと調べてみました。

Message Queue の定番といえば RabbitMQ、開発も活発、ということではじめて使ってみました。多くの言語でのクライアントが存在していていい感じです。

RabbitMQ は多機能で使いにくいかなと思ったのですが、私の用途では、

Introduction
Work Queues
Tutorials for using RabbitMQ in various ways

の3つをみれば十分でした。英語ですが非常に分かりやすい説明です。RabbitMQ にはおおまかに、Work Queue、Pub/Sub, Routing, Topics, RPC の5つの機能があるのですが、Kestrel 的なものを考えている私にとっては Work Queue で十分です。

上記のリンクを読んで、コードを動かしてみればほぼ問題ないかと思います。RabbitMQでは、設定次第ではたとえばワーカーが落ちてしまっても、タスクは再送されるのでいい感じです。

クラスタリングや可用性を考えるとキリがないですが、単純な使い方も出来るということが分かりました。いろんな意味でおすすめ MQ です。

PHPで公開鍵暗号を使った認証

一般的なWebサービスでは、クライアントの認証時にusernameとpasswordを入力してもらい、DBにあるsaltを使って入力されたpasswordを何度もhash化し、DBにあるそれと比較します。

saltは何バイト以上だとか、hash化の回数は何回だとか、hash関数はどうだとか、いろいろとあって面倒な上に間抜けな感じがするので、公開鍵暗号を使って認証を行ってみました。公開鍵をサーバーにおいておくならば、仮に流出しても問題ありません。公開鍵の名前の通り公開してもよいほどでしょう。もっと先進的なことを考えるのであればaltcoin等のアドレスにしてしまうなどもありでしょう。

スマートフォン等のクライアントであれば、秘密鍵を生成して保存しておくことは現実的です(ブラウザだと、cookieにせよ何にせよ消えてしまうのでよろしくないと思います)。

流れとしては、こんな感じです。

クライアントが秘密鍵を生成

クライアントがサーバーにusernameと公開鍵を渡す

サーバーはusernameと公開鍵を受け取って、DBに保存してユーザーを作成する

クライアントが「username」「データ」と、「そのデータをハッシュ化したものを秘密鍵で暗号化したもの」をサーバーに送信

サーバーは、usernameからDBを引いてきてそのユーザーの公開鍵を取り出す。次に受け取ったデータをハッシュ化する(hash1)。次に、「そのデータをハッシュ化したものを秘密鍵で暗号化したもの」を公開鍵で復号する(hash2)。hash1とhash2が一致したら、正しいユーザーによるリクエスト、一致しなければ不正なリクエストとする

今回は、クライアント・サーバーともにPHPでプログラムを作成しました。
– client.php (クライアントのプログラム。公開鍵・秘密鍵を生成して、下記のサーバーのプログラムにアクセスする)
– create_account.php (サーバーのプログラム、ユーザーを作成する)
– auth.php (サーバーのプログラム、ユーザーの認証を行う)

動作方法は以下です。

# RedisをPort 26379で立ち上げる
$ redis-server --port 26379

# PHPのサーバーのプログラムをPort 28080で立ち上げる
$ php -S localhost:28080

# 実行する
$ php client.php

実行結果の例は、
Client instance generated.

Private and public keys are generated.

Accessed to create_account.php
Response from server: 
Success to create an account.

Accessed to auth.php
Response from server: 
Success to auth.

Client instance generated.

Private and public keys are generated.

Accessed to create_account.php
Response from server: 
Success to create an account.

Accessed to auth.php
Response from server: 
Error. auth failed.

です。client1とclient2を作り、client1で正常な動作を確認し、client2ではauth.phpについてclient1で暗号化したものを渡して意図的にエラーを出しています。

認証時、「任意の平文」と「任意の平文のハッシュを秘密鍵で暗号化」を2つ渡すのが面白いですね。デジタル署名を少し理解ました。暗号の世界は奥が深いです。

client.php
<?php

define('SERVER_HOST', 'localhost');
define('SERVER_PORT', '28080');

/* create an account */
$client1 = new Client();
$client1->generateKeys();
$username1 = bin2hex(openssl_random_pseudo_bytes(6));
$client1->createAccount($username1);

/* prepare data */
$data = 'foobar';

/* hash data */
$hash = sha1($data);

/* encrypt hashed data */
$encrypted_hash = $client1->encryptByPrivKey($hash);

/* auth (this will be ok) */
$client1->auth($username1, $data, bin2hex($encrypted_hash));


/* create another account */
$client2 = new Client();
$client2->generateKeys();
$username2 = bin2hex(openssl_random_pseudo_bytes(6));
$client2->createAccount($username2);

/* auth (auth will be failed because $encrypted_hash is encrypted by user1's PrivKey) */
$client2->auth($username2, $data, bin2hex($encrypted_hash));

class Client {

	public $pubKey;
	public $privKey;

	function __construct() {
		echo 'Client instance generated.' . "\n\n";
	}

	function generateKeys() {
		$config = array(
			"private_key_bits" => 512,
			"private_key_type" => OPENSSL_KEYTYPE_RSA,
		);

		/* Create the private and public key */
		$res = openssl_pkey_new($config);

		/* Extract the private key from $res to $privKey */
		openssl_pkey_export($res, $privKey);

		/* Extract the public key from $res to $pubKey */
		$pubKey = openssl_pkey_get_details($res);
		$pubKey = $pubKey["key"];

		$this->privKey = $privKey;
		$this->pubKey = $pubKey;
		echo 'Private and public keys are generated.' . "\n\n";
	}

	function encryptByPrivKey($data) {
		openssl_private_encrypt($data, $encrypted, $this->privKey);
		return $encrypted;
	}

	function createAccount($username) {
		$api_name = 'create_account.php';
		$params = array('username' => $username, 'pubkey' => $this->pubKey);
		$res = $this->httpPost($api_name, $params);
		echo 'Accessed to create_account.php' . "\n";
		echo "Response from server: \n" . $res . "\n\n";
	}

	function auth($username, $data, $encrypted_hash) {
		$api_name = 'auth.php';
		$params = array('username' => $username, 'data' => $data, 'encrypted_hash' => $encrypted_hash);
		$res = $this->httpPost($api_name, $params);
		echo 'Accessed to auth.php' . "\n";
		echo "Response from server: \n" . $res . "\n\n";
	}

	private function httpPost($api_name, array $params) {
		$data = http_build_query($params, "", "&");

		$header = array(
			"Content-Type: application/x-www-form-urlencoded",
			"Content-Length: " . strlen($data)
		);

		$context = array(
			"http" => array(
				"method" => "POST",
				"header" => implode("\r\n", $header),
				"content" => $data
			)
		);
		return file_get_contents('http://' . SERVER_HOST . ':' . SERVER_PORT . '/' . $api_name, false, stream_context_create($context));
	}

}


create_account.php
<?php

if (!isset($_POST['username']) or !isset($_POST['pubkey'])) {
	die('Error. Invalid query.');
}
if (preg_match('/^[a-z0-9]{1,12}$/', $_POST['username']) === FALSE) {
	die('Error. Invalid username.');
}

$redis = new Redis();
$redis->connect('localhost', 26379, 2.0);

$username = $_POST['username'];
$pubkey = $_POST['pubkey'];

/* check username duplication */
if ($redis->exists($username) === TRUE) {
	die('Error. The posted username already exists.');
}

/* count up for userid */
$userid = $redis->incr('users:total');

/* register account */
$redis->hMset('userid:' . $userid, array('username' => $username, 'pubkey' => $pubkey));
$redis->set($username, (int) $userid);

die('Success to create an account.');


auth.php

<?php

if (!isset($_POST['username']) or !isset($_POST['data']) or !isset($_POST['encrypted_hash'])) {
	die('Error. Invalid query.');
}
if (preg_match('/^[a-z0-9]{1,12}$/', $_POST['username']) === FALSE) {
	die('Error. Invalid username.');
}
if (preg_match('/^[a-z0-9]{1, 4096}$/', $_POST['data']) === FALSE) {
	die('Error. Invalid data.');
}
if (preg_match('/^[a-z0-9]{1, 4096}$/', $_POST['encrypted_hash']) === FALSE) {
	die('Error. Invalid encrypted_hash.');
}

$redis = new Redis();
$redis->connect('localhost', 26379, 2.0);

$username = $_POST['username'];
$data = $_POST['data'];
$encrypted_hash = hex2bin($_POST['encrypted_hash']);

/* check username */
if ($redis->exists($username) === FALSE) {
	die('Error. The username does not exist.');
}

/* get userid */
$userid = (int) $redis->get($username);

/* get pubkey from userinfo */
$userinfo = $redis->hGetAll('userid:' . $userid);
$pubkey = $userinfo['pubkey'];

/* get hash from encrypted_hash */
openssl_public_decrypt($encrypted_hash, $decrypted, $pubkey);
$hash_from_encrypted = $decrypted;

/* get hash from data */
$hash_from_data = sha1($data);

if ($hash_from_encrypted !== $hash_from_data) {
	die('Error. auth failed.');
}

die('Success to auth.');