ハッシュ関数は色々な場面で利用できます。このエントリではハッシュ関数の使い方、特にリクエストにサインする方法として、データベースを使わずURI個別のCSRFトークンを生成しバリデーションする方法を紹介します。
論理的な背景
まず前提条件となる知識を整理し、なぜこのエントリのようなハッシュ関数の使い方をしているのか紹介します。
大別するとハッシュ関数には二種類あります。ハッシュ関数の種別を正しく区別することが重要です。
- 暗号学的なハッシュ関数 – SHA2など
- 暗号学的でないハッシュ関数 – CRCなど
暗号学的なハッシュ関数には次の特徴があります。
- 同一のハッシュ値であるのに、そっくりだが実は異なるというようなメッセージの作成が不可能であること
- ハッシュ値から、そのようなハッシュ値となるメッセージを得ることが(事実上)不可能であること(原像計算困難性、弱衝突耐性)
- 同じハッシュ値となる、異なる2つのメッセージのペアを求めることが(事実上)不可能であること(強衝突耐性)
セキュリティ関連の処理に利用されるハッシュ関数は主に暗号学的なハッシュ関数です。1
実際の暗号学的ハッシュ関数利用の問題
暗号学的ハッシュであれば、以下の文字列連結によるハッシュ値も暗号学的ハッシュの定義から安全である2 はずです。
<?php $secure_hash = hash('sha256', 'some data' . 'key or salt'); ?>
しかし、実際にはハッシュ関数アルゴリズムの特徴により’some data’, ‘key or salt’, $secure_hashを予測や分析することができてしまう事があります。
HMACアルゴリズム
暗号学的ハッシュ関数、とされるハッシュ関数を利用している場合でもリスクがあります。認証や認可など絶対的な安全性が必要な場合、わずかなリスクでも許容できないです。この為、暗号学的ハッシュ関数をより安全に利用するため、HMAC (Hash-based Message Authentication Code) が考案されています。
HMACを利用した場合、暗号学的ハッシュ関数に多少の問題があった場合でも高い安全性を維持できることが知られています。PHPにはhash_hmac関数があります。
HKDFアルゴリズム
HKDF(HMAC-based Extract-and-Expand Key Derivation Function)はRFC 5869で標準化されているハッシュ関数です。KDF (Key Derivation Function)を標準化し互換性を持たせる意味を持っています。HKDFは以下の入力を受け取り
- 鍵を導出する元となる入力 (Input Key Material)普通は秘密鍵(APIキーやパスワードなど)
- 鍵を導出 (Derivation) させる為の鍵 (Salt)事前共有鍵。秘密でなくても構わないが、ユーザーに操作を許してはならない。
- 鍵のコンテクスト(Context)秘密でない鍵のコンテクスト。鍵が有効なコンテクスト(プロトコルのバージョン番号、ユーザーID)など。
- 鍵の長さ (Length)主に長い鍵が必要な場合に指定。デフォルトはハッシュのサイズ。ほとんどの場合、指定の必要がない。
HMACを利用して安全な鍵を導出します。3
Step 1: Extract HKDF-Extract(salt, IKM) -> PRK Step 2: Expand HKDF-Expand(PRK, info, L) -> OKM The output OKM is calculated as follows: N = ceil(L/HashLen) T = T(1) | T(2) | T(3) | ... | T(N) OKM = first L octets of T where: T(0) = empty string (zero length) T(1) = HMAC-Hash(PRK, T(0) | info | 0x01) T(2) = HMAC-Hash(PRK, T(1) | info | 0x02) T(3) = HMAC-Hash(PRK, T(2) | info | 0x03) ...
詳しくはRFC 5869を参照してください。
HKDFが便利な理由は、
- 暗号学的ハッシュ関数でデータと鍵(key)となる値に文字列(saltなどを)連結する
とリスクを伴うのと同じ理由で、
- ”Salt”に秘密でない”Context”を文字列連結する
とリスクを伴います。4 HKDFは”Salt”と”Context”を分離することにより、安全性を確保しています。
HKDFはHMACを利用して安全性を維持しています。同様な事はHMACだけでも可能です。
<?php $hkdf_like_key = hash_hmac('sha256', hash_hmac('sha256', 'some key', 'salt'), 'context'); // 上記は下記のような文字列連結がないので安全 $unsecure_key = hash_hmac('sha256', 'some key', 'salt' . 'context'); ?>
このエントリのCSRF対策では上記のようにHMACを利用し、URIをコンテクストとするCSRFトークンを生成します。
CSRFトークンの生成
Webアプリではハッシュ関数を利用したリクエストの検証がよく利用されます。例えばCSRF対策やサイン済みURL5がWebアプリで最もよく利用されていると思います。
ここ例ではCSRFトークンの有効期限を厳密に管理していません。厳密でない部分はセキュリティ面で弱いと言えますが、ユーザーがフォーム入力の途中で何時間中座しても有効なCSRFトークンが残っているので利便性は高くなっています。6
HMACによるCSRFトークンの生成
セッションIDと同じで同じCSRFトークンを使い続けるのはよくありません。ある程度定期的に更新すべきです。HMACを利用し、シードとなる情報から新しい鍵(CSRFトークン)を生成する方法を紹介します。
以下のサンプルコードの場合、180秒毎にCSRFトークンを更新し、最大5までのトークンを保持する。(トークンの最大有効期限はアクセス頻度による。ユーザーのアイドル時間が長かった場合に、CSRFトークンの有効期限切れを防止したい場合などに便利な方法です)
<?php define('CSRF_TOKEN_EXPIRE', 180); // 1つのCSRFトークンの有効期限(秒) define('CSRF_TOKENS', 5); // 過去5つまでのトークンを有効なトークンとする session_start(); // Session変数初期化 if (empty($_SESSION['CSRF_TOKEN_SEED'])) { // 暗号学的に安全なCSRFトークンのシードを作成 $_SESSION['CSRF_TOKEN_SEED'] = random_bytes(32); } if (empty($_SESSION['CSRF_TOKEN_COUNT'])) { // トークンのカウンター $_SESSION['CSRF_TOKEN_COUNT'] = 1; } if (empty($_SESSION['CSRF_TOKEN_EXPIRE'])) { // タイムスタンプの設定 $_SESSION['CSRF_TOKEN_EXPIRE'] = time(); } function csrf_get_token() { // 最後に発行したトークンの更新期限チェック if ($_SESSION['CSRF_TOKEN_EXPIRE'] + CSRF_TOKEN_EXPIRE < time()) { $_SESSION['CSRF_TOKEN_COUNT']++; $_SESSION['CSRF_TOKEN_EXPIRE'] = time(); } return hash_hmac('sha256', $_SESSION['CSRF_TOKEN_SEED'], $_SESSION['CSRF_TOKEN_COUNT']); } function csrf_validate_token($browser_token) { for($i = 0; $i < CSRF_TOKENS; $i++) { $token = hash_hmac('sha256', $_SESSION['CSRF_TOKEN_SEED'], $_SESSION['CSRF_TOKEN_COUNT'] - $i); if (hash_equals($browser_token, $token)) { return TRUE; } } return FALSE; } // フォームにCSRFトークンを埋め込む場合これを利用する $csrf_token = csrf_get_token(); // ブラウザーが送信したCSRFトークンの検証 $bworser_token = $_POST['csrf_token_from_client']; if (!csrf_validate_token($browser_token)) { // 無効なリクエスト throw new Exception('無効なリクエストが送信されました'); } // 有効なリクエスト
単純なCSRFトークン
CSRFトークンに有効期限を設ける場合、様々な実装方法があります。ハッシュを使わなくてもCSRFトークンは管理できます。最も解りやすい方法はトークンの値をキー、有効期限を値とする配列を作って管理する方法があります。
<?php define('SRF_TOKEN_EXPIRE', 180); // 1つのCSRFトークンの有効期限(秒) define('CSRF_TOKENS', 5); // 過去5つまでのトークンを有効なトークンとする session_start(); // Session変数初期化 if (empty($_SESSION['CSRF_TOKENS'])) { $_SESSION['CSRF_TOKENS'][] = [ bin2hex(random_bytes(32)) => time() ]; } function csrf_get_token() { $csrf_tokens = array_slice($_SESSION['CSRF_TOKENS'], count($_SESSION['CSRF_TOKENS'])-1, 1); // 1要素の配列だが、キーと値を取得する為にforeachを使う foreach($csrf_tokens as $tk => $expire) { if ($expire + CSRF_TOKEN_EXPIRE < time()) { $tk = bin2hex(random_bytes(32)); $_SESSION['CSRF_TOKENS'][] = [ $tk => time() ]; array_splice($_SESSION['CSRF_TOKENS'], 1, CSRF_TOKENS); } $token = $tk; // foreachがスコープを持つようになった場合に備えて別の変数に保存 } return $token; } function csrf_validate_token($browser_token) { foreach($_SESSION['CSRF_TOKENS'] as $token) { foreach($token as $key => $expire) { if (hash_equals($browser_token, $key)) { return TRUE; } } } return FALSE; }
CSRFトークンを保存する配列を使った場合は繰り返しhash_hmac()を呼ばずに済むのでCPU効率が良いでが、セッション配列が大きくなりスペース効率が悪いです。
その反対に、hash_hmac()を使う方法は有効なCSRFトークンをハッシュ計算で算出するのでCPU効率は悪いですが、セッション配列はコンパクトになりスペース効率が良いです。
それぞれ特徴が異るのでニーズに合った方法を使用すると良いでしょう。
HMACによるURL固有のCSRFトークン
やっと本題のURI固有のCSRFトークン生成の紹介です。
前のCSRFトークンの例ではあまりハッシュ関数を利用するメリットが感じられないかも知れません。今度はURL固有のCSRFトークンの生成方法を紹介します。前の例では最大5つまでのCSRFトークンしか保存しなかったので$_SESSION配列に保存しても大きな問題はありません。今回はURL固有のCSRFトークンを生成します。全てのURLに対して固有のCSRFトークンを$_SESSION配列に保存するのは現実的ではありません。
HMACを使えば簡単かつ安全にURI固有のCSRFトークンを実現できます。「HMACによるCSRFトークンの生成」のcsrf_get_token()とcsrf_validate_token()を修正するだけです。
function csrf_get_token_with_uri($uri) { // 有効期限チェック if ($_SESSION['CSRF_TOKEN_EXPIRE'] + CSRF_TOKEN_EXPIRE < time()) { $_SESSION['CSRF_TOKEN_COUNT']++; $_SESSION['CSRF_TOKEN_EXPIRE'] = time(); } // CSRF対策なのでCSRFトークンはURIに含まれないことが前提 return hash_hmac('sha256', hash_hmac('sha256', $_SESSION['CSRF_TOKEN_SEED'], $_SESSION['CSRF_TOKEN_COUNT']), $uri); } function csrf_validate_token_with_uri($browser_token, $uri) { for($i = 0; $i < CSRF_TOKENS; $i++) { // CSRF対策なのでCSRFトークンはURIに含まれないことが前提 $token = hash_hmac('sha256', hash_hmac('sha256', $_SESSION['CSRF_TOKEN_SEED'], $_SESSION['CSRF_TOKEN_COUNT'] - $i), $uri); if (hash_equals($browser_token, $token)) { return TRUE; } } return FALSE; }
$uriには$_SERVER[‘REQUEST_URI’]などを設定して使います。
変更した箇所は
- パラメータに$uriを追加
- HMACによるハッシュでコンテクストとして$uriを追加
しただけです。重要な変更はHMACを使い、鍵情報とコンテクスト情報を分離してHKDFライクなハッシュ値を得ている部分です。
hash_hmac('sha256', hash_hmac('sha256', $_SESSION['CSRF_TOKEN_SEED'], $_SESSION['CSRF_TOKEN_COUNT']), $uri);
最終的なハッシュ値となるhash_hmac関数(外側のhash_hmac())の鍵となるデータを以下のように
hash_hmac('sha256', $_SESSION['CSRF_TOKEN_SEED'], $_SESSION['CSRF_TOKEN_COUNT']),
別のhash_hmac関数を使い、HKDFでいうコンテクストパラメータとなる$uriと分離している所です。コンテクストパラメータにはどのような値を設定しても生成されるハッシュ値(と内側のhash_hmac関数で作った鍵)の安全性に影響を与えません。
前のハッシュ版と同じくCPUリソースは使いますが、使用するCSRFトークンが幾ら増えても効率は変わりません。同じことを$_SESSIONや他のデータベースを使ってURI固有のCSRFトークンを管理するとかなり多くのリソースが必要になることが多いでしょう。7
このCSRFトークンの問題点は
- 有効期限が明示的に指定されていない
ことです。これを解消するには、有効期限パラメーターを追加し、infoに加えて処理します。
まとめ
ハッシュ関数を上手く使うと、余計なリソースを使わずに、URI毎に異るCSRFトークンを付与できて安全性が高まります。URI毎にCSRFトークンが異るので、仮に管理者ユーザーのCSRFトークンが盗まれても他のURIに使えないのでリスクが低減できます。
コンテクストには何を入れても構わない8ので、ユーザーエージェント/IPアドレス/IPの地域などのコンテクスト情報を入れてより安全なCSRFトークンを作ることも簡単です。
Saltがカウンターのみなので、秘密鍵(CSRF_TOKEN_SEED)の安全性は若干劣ります。これを改善するにはSaltにもCSPRNGから取得したランダムな値を追加すれば良いです。e.g. $random_static_salt . $_SESSION[‘CSRF_TOKEN_COUNT’] 9
ユーザーが操作をしない場合、トークンが長い期間有効になります。これを回避したい場合、タイムスタンプをURI情報に追加して管理すれば、最大の有効期限を設定できます。
CSRF対策だけでなく、同様の手法を用いて
などをデータベースなしで行えます。暗号学的ハッシュ関数とHMACを知っていると色々な応用が可能です。
- CRCハッシュもシステムの信頼性向上に役立ちます。セキュリティの基本要素には”完全性”も含まれているので、暗号学的なハッシュ関数だけがセキュリティ関連の処理に利用されているのではありません。 ↩
- ‘key or salt’が予測不可能なデータである場合、生成された$secure_hashの値、入力値である’some data’が秘密の場合その値も予測不可能であるので安全 ↩
- HKDFで導出された鍵の安全性を保つ為には、ユーザーが設定した任意のSaltで導出された鍵を公開してはならない。つまり、Saltに任意の値を設定できても導出鍵が秘密であれば安全性は保たれる。別の言い方をすると、導出鍵が公開の場合、ユーザーがSaltに任意の値を設定できてはならない。 ↩
- RFC 5869ではKeyの安全性の為にSaltはユーザー制御可能であってはならない、としています。しかし、Infoはユーザー制御可能であってはならない、とはしていません。 ↩
- 例えば、Amazon AWS S3のPreSigned URLはHMACを利用してURLにキーおよび有効期限を設定しています。 ↩
- もし有効期限を厳密に管理する必要がある場合は、コンテクストに有効期限情報を追加してブラウザから送信させるようにするだけで実現できます。 ↩
- 10万セッションを保存しているサイトでユーザーあたり平均100URI、CSRFトークン履歴が5の場合、5000万のCSRFトークンを管理しなければならないが、この方法の場合はセッションに保存されたシードとカウンターのみで済む。 ↩
- コンテクストはそもそも秘密情報ではない情報です。コンテクストには何を入れても構いません。文字列連結で必要な数だけのコンテクスト情報を入れても、暗号学的な安全性が維持できます。 ↩
- そもそも十分なエントロピーを持つ鍵(CSRF_TOKEN_SEED)には低いエントロピーのSaltでも構わないです。このため、この文字列連結のリスクは無視しても構いません。 ↩