特定の処理を行うデータを渡すURLの作り方

(Last Updated On: 2020年5月8日)

Webアプリケーションを作っているとデータの完全性と機密性を保ちつつ「特定の処理を行うデータを渡すURL」を作りたくなる場合があります。

例えば、特定のURLをクリックすると特定ユーザーの連絡先にユーザーを登録する、などです。

考えられるURL仕様

連絡先がデータベース化されているWebアプリがあるとします。この連絡先にユーザーを登録する処理で考えられる仕様には

  1. 「ユーザー」を連絡先に登録できるURLであること
  2. 秘密の「指定したユーザー」のみ1の「ユーザー」を連絡先に登録できること
  3. 連絡先登録に利用するURLはWebアプリのみが作成できること
  4. 可能な限りサーバーのデータベースは利用しないこと
  5. URLを見て登録するユーザーとURLの有効期限が分かること

などが考えられます。

仕様1の注意点

  • 登録する前に対象ユーザーの許可を必要とする

これが無いと悪意のある者がメールやSNSなどでURLを拡散し意図しないユーザーの連絡先に登録が可能になります。

「ユーザーXXXXを連絡先に登録しますか? – OK/キャンセル」

と選べるようにしないと問題の原因になります。

仕様2の注意点

  • 「指定したユーザー」が誰なのか分からないようにする

一般にユーザーIDは公開情報ですが、どのユーザーがどのユーザーと関係があるのかといった情報は秘密にした方が良い情報です。秘密にする場合は暗号化が必要です。

秘密情報にしない場合は暗号化は必須ではありません。

仕様3の注意点

自由にユーザーが連絡先登録URLを作れる/自由に再利用できるようでは困ります。

改ざん不可能な「有効期限」が必要であり、Webアプリが生成したURLであること「検証可能」であることが必要です。

実装方針

必要な情報は

  • 連絡先に登録するユーザーID(1つ、自分自身のみ)
  • 登録を許可したユーザーID(1つ以上)
  • 有効期限

となります。

暗号を使えば暗号化した情報の改竄防止と秘密保持の両方が実現します。全ての情報の暗号化は仕様5によりできません。

暗号が使えない部分はハッシュを使います。このような用途の場合はHKDFが向いています。HMACでも構わない、とも考えられますが、HKDFはユーザーが自由に設定できる情報(この場合、ユーザーID、有効期限の自由度は高い)がある場合でもHMACより高い安全性を確保できます。

好ましくない例:HMAC

$hash = hash_hmac('SHA3-256',  $userid.$expires.$invited_userid.$salt, $secret_key);

SHA2/3などのハッシュを取るだけ、は十分に安全とは考えられないのでやめましょう。

悪い例:Hash

$hash = hash('SHA3-256', $userid.$expires.$invited_userid.$salt);

HKDFはそもそもユーザー制御可能な情報(この場合はユーザーID、有効期限)が含まれる場合でも十分な安全性を確保できるよう考えられています。素直にHKDFを利用すると良いでしょう。

良い例:HKDF

$hash = hash_hkdf('SHA3-256',  $secret_key, 0,  $userid.$expires.$invited_userid, $salt);

HKDFは$saltを別引数とすることで、ユーザーが制御可能なinfo引数(上記の場合、$userid.$expires.$invited_userid)をハッシュデータにした場合でも、$secret_keyの統計的解析を困難にします。

今回は最低限のサンプルコードのみで最低限の動作するようにします。ユーザー認証などのコードは「無い」ですが、必要なコードは「在る」ものとして考えてください。

実装

実装はURLの生成サンプルコードとURLの検証のサンプルコードの2つ作ります。

URL生成コード

create_url.php

<?php
session_start();
// 現在のユーザーIDはyohgakiとする
$userid = 'yohgaki';
// 招待するユーザーIDはuser1, user2とする
$invited_users='user1,user2';
// 有効期限は7日間とする(少し雑に)
$expires = date('Y-m-d', time()+86400*7);


// OpenSSL用の秘密鍵
$openssl_key = base64_decode('6A9YPrva0ZSEwtCom01N22/XYom6BxuTWzhd8YkWVcA=');
// HKDF用の秘密鍵
$hkdf_key = base64_decode('mV4GQIss63ibynt+ixODJutk1+3GO7vEayzpwSCm0kY=');

// OpenSSLで$invited_usersを暗号化する。
// IV (Initialization Vector)
$iv = random_bytes(16);
$encrypted_invited_users=openssl_encrypt($invited_users, 'AES-256-CBC', $openssl_key, 0, $iv);

// HKDFのハッシュ値を取得する
$hkdf_salt = random_bytes(16);
$hkdf_hash = hash_hkdf('SHA3-256', $hkdf_key, 0, $userid.$expires.$encrypted_invited_users.$iv, $hkdf_salt);

// Base64 URL encode/decode
function base64url_encode($data) {
	return strtr(base64_encode($data), ['+'=>'-','/'=>'_', '='=>'']);
}
function base64url_decode($data) {
	return base64_decode(strtr($data, ['-'=>'+','_'=>'/']));
}

// URLの生成
$uid = rawurlencode($userid);
$exp = rawurlencode($expires);
$i   = base64url_encode($iv);
$e   = base64url_encode($encrypted_invited_users);
$h   = base64url_encode($hkdf_hash);
$s   = base64url_encode($hkdf_salt);
$url = 'http://127.0.0.1:8888/validate.php?uid='.$uid.'&exp='.$exp.'&i='.$i.'&e='.$e.'&h='.$h.'&s='.$s;

?>
<html>
<head></head>
<body>
<a href='<?php echo $url ?>';>Click Here</a>
</body>
</html>

URL検証コード

この検証コードには

「ユーザーXXXXを連絡先に登録しますか? – OK/キャンセル」

と問い合わせるコードは無いので追加する必要があります。サンプルコードとしては必要ないのでリクエストされたURLパラメーターの検証のみでOKとします。

validate.php

<?php
session_start();
// 現在のユーザーIDはuser1とする
$userid = 'user1';

// OpenSSL用の秘密鍵
$openssl_key = base64_decode('6A9YPrva0ZSEwtCom01N22/XYom6BxuTWzhd8YkWVcA=');
// HKDF用の秘密鍵
$hkdf_key = base64_decode('mV4GQIss63ibynt+ixODJutk1+3GO7vEayzpwSCm0kY=');


// Base64 URL encode/decode
function base64url_encode($data) {
	return strtr(base64_encode($data), ['+'=>'-','/'=>'_', '='=>'']);
}
function base64url_decode($data) {
	return base64_decode(strtr($data, ['-'=>'+','_'=>'/']));
}


// エラーと例外処理 - とりあえず最低限だけ。全てのエラー/例外で停止
function exception_error_handler($severity, $message, $file, $line) {
    if (!(error_reporting() & $severity)) {
        // This error code is not included in error_reporting
        // return;
    }
    throw new ErrorException($message, 0, $severity, $file, $line);
    exit;
}
set_error_handler("exception_error_handler");

function exception_handler($exception) {
    echo "Invalid Request: " , $exception->getMessage(), "\n";
    http_response_code(400); // Bad request
    exit;
}
set_exception_handler('exception_handler');


// パラメーターを設定
$uid                     = $_GET['uid'];
$expires                 = $_GET['exp'];
$iv                      = base64url_decode($_GET['i']);
$encrypted_invited_users = base64url_decode($_GET['e']);
$hkdf_hash               = base64url_decode($_GET['h']);
$hkdf_salt               = base64url_decode($_GET['s']);

// HKDFでバリデーション
if (hash_equals($hkdf_hash, hash_hkdf('SHA3-256', $hkdf_key, 0, $uid.$expires.$encrypted_invited_users.$iv, $hkdf_salt)) !== TRUE) {
	throw new Exception('HKDF hash mismatch');
}

// 有効期限の確認
if (time() > strtotime($expires)) {
	throw new Exception('URL expired');
}

// 暗号データの復号
$invited_users = openssl_decrypt($encrypted_invited_users, 'AES-256-CBC', $openssl_key, 0, $iv);

// 現在のユーザーIDが含まれるか確認
if (in_array($userid, explode(',', $invited_users)) !== TRUE) {
	throw new Exception('Not an invited user');
}


// OK
?>
<html>
<head></head>
<body>
<pre>
Your request will be processed.

「ユーザーXXXXを連絡先に登録しますか? - OK/キャンセル」

などと確認するコードも実装する。
</pre>
</body>
</html>

テスト

create_url.php, validate.phpを保存したディレクトリで

php -S 127.0.0.1:8888

としてPHPのビルトインWebサーバーを起動します。その後、

http://127.0.0.1:8888/create_url.php

にアクセスしURLを生成し、Click Here をクリックして検証スクリプトにアクセスします。

クリックしたURLは妥当であるため

Your request will be processed.

と表示されるはずです。URLパラメーターを一つでも改ざんするとHKDFハッシュの不一致などでエラーになるはずです。

Invalid Request: HKDF hash mismatch

まとめ

別の実装としてはリクエストにランダム鍵を設定して、その鍵と紐づいたデータベースレコードの中にある情報を利用し、連絡先に追加する実装も可能です。

この場合、少なくとも有効期限内の鍵とそのデータをデータベースに保存し検索できるようにしておく必要があります。

暗号とHKDFを利用するとデータベース無しで

  • 有効期限を付けて、特定ユーザーの連絡先に秘密にした特定ユーザーを追加する

といった処理を実現できます。

PHPのHKDF関数であるhash_hkdf()は以下のシグニチャを持っています。

hash_hkdf ( string $algo , string $ikm [, int $length = 0 [, string $info = '' [, string $salt = '' ]]] ) : string

hash_hkdf()を利用するポイントは

  • ハッシュのアルゴリズム($algo)にはSHA3-256などを利用する
  • 必ず十分にランダムな$saltを設定する。saltはオプションではなく必須。秘密のsaltの方が安全性が高くなるが、秘密である必要はない。(利用方法にもよる)
  • $ikmにはユーザーが自由に制御可能なデータは含めない。基本、文字列連結はNG。通常は秘密の鍵データ
  • $infoにはユーザーが制御可能なデータを含めても構わない。通常、公開データ。文字列連結しても構わない

暗号学的に安全なハッシュ関数が本当に完璧に安全であれば、HKDFを利用する必要はありません。しかし、現実には完璧なハッシュ関数は存在せず、入力微妙に変えることにより秘密情報の統計処理などが可能になるリスクがあります。HKDFは秘密情報($ikm)と非秘密情報($info)を別々にHMACを2回適用することによりこのリスクを低減します。(HKDF = HMAC based Key Derivation Function)

HMACのNG使用例:

$unsesure_hash = hash_hmac('SHA3-256', $userid.$encrypted_invited_users.$expires.$hkdf_salt, $hkdf_key);

高い安全性が必要な場合、このようなHMACの使い方は好ましくありません。これでも直ぐに危険ではないですが、無用にリスクを増やしてしまいます。

$hkdf_hash = hash_hkdf('SHA3-256', $hkdf_key, 0, $userid.$encrypted_invited_users.$expires, $hkdf_salt);

とする方が良いです。saltが別パラメーターになっている点がHKDFの特徴です。RFCではsaltをオプションとする仕様は、saltをサポートできない古いシステムに対応する為だけにオプションにしている、エントロピーが少ないsaltでもハッシュの著しく強くする、としています。

PHPのhash_hkdf()関数のシグニチャを見るとオプションで良いように見えますが、saltは必須パラメーターです。saltパラメーターを使わないと、HMACではなくHKDFを使う意味を半減させます。

暗号を利用する際のポイントは

  • 秘密鍵の長さは暗号がサポートする最大効率の長さのランダムバイナリデータにする
  • IVの長さは暗号がサポートする最大の長さランダムバイナリデータにする

秘密鍵とIVが最大限の長さのランダムバイナリデータの場合に最大の強さになります。

このサンプルコードは秘密鍵(HKDF、AES)をハードコードしていますが、これらの秘密鍵は定期的に自動更新されるよう実装するのが理想的です。鍵のバージョン情報もURLに入れて鍵管理をするのがオススメです。

投稿者: yohgaki