JWTをPHP(php-jwt)で試してみる

追記@2015-04-03

やはりいろいろと勘違いしておりました。JWTでencodeすれば暗号化されると思っていたのですが、暗号化ではなく署名が含まれいて改竄困難、の間違いでした。

たとえば↓の例で、

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g

というjwtによるトークンが出てきますが、

<?php
$header = urlsafeB64Decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9');
echo $header;

echo "\n";

$payload = urlsafeB64Decode('eyJrZXkxIjoidmFsdWUxIn0');
echo $payload;


としてやると(urlsafeB64Decodeはphp-jwtに含まれています)、

{"typ":"JWT","alg":"HS256"}
{"key1":"value1"}

となります。つまりjwtの文字列はbase64エンコードされいるだけの実質平文なわけです。もちろん署名が入っているので改竄困難ですが。

また、脆弱性が見つかったらしいので(時間があれば脆弱性を付く例を書きたいのですが。。。)、ご注意下さい。参考リンクはこちら→ https://auth0.com/blog/2015/03/31/critical-vulnerabilities-in-json-web-token-libraries/


JWT(JSON Web Token)の存在を知ったのでPHPで試してみました。この仕組を使えばユーザーの認証を保存する必要がなくなって効率的だと思います。おそらくRuby On Railsでこの仕組を使っています。

既存の仕組みだと、

ユーザーがusername/passwordをサーバーに送信

サーバーはusername/passwordを受け取って、検証

サーバーは、その情報が正しければランダムな文字列をデータベースに保存してさらにユーザーに送信。正しくなければユーザーに失敗であると返却

ユーザーはサーバーから送られてきたランダムな文字列を受け取ってcookieにセットし、その後のサーバーのやりとり全てでそのランダムな文字列を送信して、本人性を証明する

サーバーはユーザーからランダムな文字列を受け取ったらそれがデータベースに存在しているか確認し、存在していればそのまま処理をして、存在しなければ失敗であるとユーザーに返却

という感じになります。問題点として、サーバー側で、

– ランダムな文字列をデータベースに保存する必要がある
– ランダムな文字列をデータベースに毎回問い合わせして確認する必要がある
– サーバー側でデータベースを用意する必要がある

などが挙げられるでしょう。JWTを使えば、ユーザーの検証のためのデータをデータベースに保存する必要がなくなります。JWTでは、

ユーザーがusername/passwordをサーバーに送信

サーバーはusername/passwordを受け取って、検証

サーバーは、その情報が正しければJWTを生成してユーザーに渡す。正しくなければユーザーに失敗と返す

ユーザーはJWTを受け取ってcookieにセット、その後のやりとり全てでこのJWTを送信して本人性を証明する

サーバーはユーザーからJWTを受け取ったらそれを検証する。このとき、データベースへのアクセスは必要ない

となります。問題点としては相対的にCPUパワーを使う点(ただ、必要な情報は鍵だけなので、その鍵を複数のノードに分散してやれば、無限にスケールします)と、JWTを生成するための鍵の保持でしょうか?鍵が公になってしまうとjwtを作り放題になり悲惨なことになります(ただ、expireを付けておくことが可能ですので鍵の交換は容易でしょう)。まあPKIってそういうものですが。ここまで書いてきてなんですが、私はこういったセキュリティやPKIにさほど詳しくないので、詳細な情報は他をあたって下さい。

さてjwtを試してみます。使うライブラリは、jwtの公式からリンクされていた、

https://github.com/firebase/php-jwt

です。まずディレクトリを作ってライブラリを入れて、そこから本番です。

$ mkdir jwt-example; cd jwt-example/;mkdir jwt;cd jwt
$ wget https://raw.githubusercontent.com/firebase/php-jwt/master/Authentication/JWT.php; wget https://raw.githubusercontent.com/firebase/php-jwt/master/Exceptions/BeforeValidException.php; wget https://raw.githubusercontent.com/firebase/php-jwt/master/Exceptions/ExpiredException.php; wget https://raw.githubusercontent.com/firebase/php-jwt/master/Exceptions/SignatureInvalidException.php; 
$ cd ..

# 標準入力からjsonを受け取って、jwtに変換するコードです。
$ vi encode.php
$ cat encode.php 
<?php

require('jwt/JWT.php');

$key = 'i_am_a_secret_key';

$json = json_decode(file_get_contents('php://stdin'));

if ($json === NULL){
	die("[Error] Invalid input.\n" . 'example: $ echo \'{"key1":"value1","expire":12345}\' | php encode.php' . "\n");
}

$jwt = JWT::encode($json, $key);

echo $jwt;

# 標準入力からjwtを受け取って、検証してjsonに戻して表示するコードです。
$ vi decode.php
$ cat decode.php 
<?php

require('jwt/BeforeValidException.php');
require('jwt/ExpiredException.php');
require('jwt/SignatureInvalidException.php');
require('jwt/JWT.php');

$key = 'i_am_a_secret_key';

$jwt = file_get_contents('php://stdin');

try {
	$decoded = JWT::decode($jwt, $key);
} catch (Exception $e){
	die("[ERROR] Invalid jwt. Detail: " . $e->getMessage() . "\n");
}
var_dump($decoded);

さて、いろいろと試して遊んでみます。↑で作ったencode.phpは標準入力でjsonを受け取って、jwtを吐き出すものなので、以下のようにして使ってみます。

$ echo '{"key1":"value1"}' | php encode.php 
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g

↑出来ました。上記のようなjsonをjwtにした結果が得られました。さて今度は逆に、jwtからjsonに戻してみます。先ほどのjwt、「eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g」を、decode.phpを使って戻せるか試します。

$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g' | php decode.php
object(stdClass)#2 (1) {
  ["key1"]=>
  string(6) "value1"
}


↑戻すことが出来ました!

つなげて書くと、

$ echo '{"foo":"bar"}' | php encode.php | php decode.php 
object(stdClass)#2 (1) {
  ["foo"]=>
  string(3) "bar"
}

となります。

おかしな文字列を入力してデコードさせてみます。先ほどのjwt、「eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g」の最後の「g」を「G」にしてみます。

# ただしい文字列 → OK
$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g' | php decode.php 
object(stdClass)#2 (1) {
  ["key1"]=>
  string(6) "value1"
}
# おかしな文字列(末尾がg→Gとなっている) → だめ
$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1G' | php decode.php 
[ERROR] Invalid jwt. Detail: Signature verification failed

となり、いい感じですね。

このPHPライブラリでは上記の単純な機能だけでなく、

– nbf (Not Before): 現在時刻が指定した時間より前なら処理しないような時間
– iat (Issued At): jwtが発行された時間
– exp (Expiration Time): jwtが無効になる時間

に対応しているようです。たとえば、

# 現在時刻を表示
$ php -r 'echo time() . "\n";'
1425560586

# nbfに↑の値を入れてエンコードとデコードを試す
$ echo '{"nbf":1425560586, "key1":"value1"}' | php encode.php | php decode.php 
object(stdClass)#2 (2) {
  ["nbf"]=>
  int(1425560586)
  ["key1"]=>
  string(6) "value1"
}

# nbfを大きな値にしてみる(1425560586 → 1525560586)と、エラーとなる。現在時刻 < nbfだからである。
$ echo '{"nbf":1525560586, "key1":"value1"}' | php encode.php | php decode.php 
[ERROR] Invalid jwt. Detail: Cannot handle token prior to 2018-05-06T07:49:46+0900

# ↑のnbfをiatに書き換えてみる (iatが未来の時間なのでエラーとなる)
$ echo '{"iat":1525560586, "key1":"value1"}' | php encode.php | php decode.php 
[ERROR] Invalid jwt. Detail: Cannot handle token prior to 2018-05-06T07:49:46+0900

# expを指定してみる (まず未来の値にしてみる→当然OK)
$ echo '{"exp":1525560586, "key1":"value1"}' | php encode.php | php decode.php 
object(stdClass)#2 (2) {
  ["exp"]=>
  int(1525560586)
  ["key1"]=>
  string(6) "value1"
}

# expを指定してみる (過去の値にしてみる→失効しているのでエラー)
$ echo '{"exp":1325560586, "key1":"value1"}' | php encode.php | php decode.php 
[ERROR] Invalid jwt. Detail: Expired token

というわけでした。nbf/iat/expといろいろとあって分かりにくい感じですが基本的にはiatとexpを使えば事足りるのではないでしょうか。

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.');

sshが繋がらないよ!というときの自分用チェックリスト

よく接続できなくて泣くので…

クライアント側:
1. ~/.ssh/configの内容は合っているか。スペリングミスやIPアドレス、ユーザー名が間違っていないか。
2. configを用いた「ssh hoge」ではなく、「$ ssh hoge@192.168.0.100 -i ~/.ssh/id_rsa」のように直打ちして確かめてみる。
3. 倉・鯖間のpingは通っているか

サーバー側(sshd側):
1. iptablesでsshのportを開けているか、開けていなければ開けてiptables再起動
2. /etc/ssh/sshd_configの設定を見直す(sshd_configで自分が使っている例(これは自宅鯖用で、Root Login OK なので、全世界に公開するときは必ず PermitRootLoginをnoにして、AllowUsersにログインユーザーを加えてください。PermitRootLoginが許可されている状態は極めて危険です。侵入されやすいです。) -> sshd_config)