PHPでパスワードの強度を調べる(その2)

(1/1)
PHPでパスワードの強度を調べる」は、「ネット越しにパスワードが行き来するので、万が一のことを考え、ここで検査したパスワードを本番認証には使わないようにしてほしい」という制約があった。そこで今回は、ネットに平文のパスワードを流すことなく、その強度をチェックするプログラムを作ることにする。

そこで、強度3の判定方法として、Wikipediaの見出しに存在するかどうかではなく、漏洩したことがあるパスワードを集めたブラックリストとのマッチングを行うことにする。
また、平文のパスワードがネットに流れないようにするため、平文を扱う処理はクライアントサイド(JavaScript)で完結し、ブラックリスト辞書の探索だけをサーバサイド(PHP)に処理させることにする。

(2024年5月4日)DECODE_TIMEの値を改訂
(2023年11月23日)ブラックリスト辞書を強化

サンプル・プログラムの実行例

PHPでパスワードの強度を調べる(その2)

目次

サンプル・プログラム

圧縮ファイルの内容
passwordStrength2.htmlサンプル・プログラム(UI:JavaScript側)
passwordStrength2API.phpサンプル・プログラム(WebAPI:PHP側)
passwords.dicブラックリスト辞書(サンプル)
pahooInputData.phpデータ入力に関わる関数群。
使い方は「PHPでGET/POSTでフォームから値を受け取る」「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。
passwordStrength2.html 更新履歴
バージョン 更新日 内容
2.0.1 2024/05/04 DECODE_TIME の値を改訂
2.0.1 2023/03/21 bug-fix
2.0.0 2023/02/23 UIをJavaScriptに分離
1.0.0 2020/02/01 passwordStrength.phpの初版
passwordStrength2API.php 更新履歴
バージョン 更新日 内容
2.0.0 2023/02/23 ブラックリスト辞書・Wikipedia参照廃止,WebAPIに変更
1.0.0 2020/02/01 passwordStrength.phpの初版
pahooInputData.php 更新履歴
バージョン 更新日 内容
1.5.0 2024/01/28 exitIfExceedVersion() 追加
1.4.2 2024/01/28 exitIfLessVersion() メッセージ修正
1.4.1 2023/09/30 コメントの訂正
1.4.0 2023/09/09 $_GET, $_POST参照をfilter_input()関数に置換
1.3.0 2023/07/11 roundFloat() 追加

プログラムの流れ

PHPでパスワードの強度を調べる(その2)

解説:ブラックリスト辞書探索API

ブラックリスト辞書探索API (POST)
URL
https://www.pahoo.org/e-soul/webtech/php02/program/passwordStrength2API.php

入力パラメータ
フィールド名 要否 内  容
version 必須 コールするAPIバージョン。"1.0.0"固定。
digest 必須 パスワードをMD5でハッシュ化した16進文字列。
応答データ(json) version APIバージョン error エラー・メッセージ(正常なら空文字) result 'match':辞書にある,'unmatch':辞書にない words 辞書に登録されているパスワード数

 148: //メイン・プログラム =======================================================
 149: //パラメータを受け取る
 150: $errmsg = '';
 151: $patterns = array('/^[0-9\.]+$/iu');
 152: $version = getValidString('version', $errmsg, $def='', 5, 5, TRUE, $patterns);
 153: if ($errmsg == '') {
 154:     if (API_VERSION == $version) {
 155:         $patterns = array('/^[0123456789ABCDEF]+$/iu');
 156:         $digest = getValidString('digest', $errmsg, $def='', DIGEST_LENGTH * 2, DIGEST_LENGTH * 2, TRUE, $patterns);
 157:     } else {
 158:         $errmsg = 'APIのバージョンが異なります';
 159:     }
 160: }
 161: $words = $ret = '';
 162: if ($errmsg == '') {
 163:     $words = countDictionary();
 164:     $ret = inDictionary(hex2bin($digest), $errmsg? 'match' : 'unmatch';
 165: }
 166: 
 167: //API応答(JSON)
 168: $arr = array(
 169:     'version'   => API_VERSION,     //APIバージョン
 170:     'error'     => $errmsg,         //エラー・メッセージ
 171:     'result'    => $ret,            //'match':辞書にある,'unmatch':辞書にない
 172:     'words'     => $words           //辞書に登録されているパスワード数
 173: );
 174: echo json_encode($arr);

PHPでブラックリスト辞書探索APIを作成した。
クライアント(JavaScript)からパスワードをハッシュ化して問い合わせることで、サーバ側にあるブラックリスト辞書を突合し、結果をJSON形式で返すWebAPIである。
ハッシュ化関数は MD5 を利用した。すでに弱点が発見されており、暗号化ハッシュとしての強度は失われているが、本用途ではそこまで強度が求められないことと、ハッシュ値が16バイト固定長と短く、辞書ファイルのサイズを小さくできることから、これを利用することにした。
このブラックリスト辞書は、「C++でパスワードの強度を調べる」で作成したものを転用している。

解説:指定したハッシュ値が辞書に存在するかどうか

 122: /**
 123:  * 指定したハッシュ値が辞書に存在するかどうかを求める.
 124:  * @param   string $digest ハッシュ値(MD5)
 125:  * @param   string $errmsg エラー・メッセージ格納用
 126:  * @return  bool TRUE:含まれている/FALSE:含まれていない,又はエラー
 127: */
 128: function inDictionary($digest, &$errmsg) {
 129:     //辞書ファイルに含まれているかどうか探す.
 130:     $ret = FALSE;
 131:     $infp = @fopen(FILENAME_DIC, 'rb');
 132:     if ($infp == NULL) {
 133:         $errmsg = "辞書ファイルが見当たりません";
 134:         return FALSE;
 135:     }
 136:     while (! feof($infp)) {
 137:         $dd = fread($infp, DIGEST_LENGTH);
 138:         if ($dd == $digest) {
 139:             $ret = TRUE;
 140:             break;
 141:         }
 142:     }
 143:     fclose($infp);
 144: 
 145:     return $ret;
 146: }

ユーザー関数 inDictionary は、指定したハッシュ値がブラックリスト辞書にあるかどうかを突合する。
ブラックリスト辞書は固定長バイナリファイルなので、whileループで回して合致するハッシュ値があるかどうかを調べている。

解説:辞書ファイルに登録されているパスワード数

 100: /**
 101:  * 辞書ファイルに登録されているパスワード数を求める.
 102:  * パスワード数は"1234","約10万"のような文字列で返す.
 103:  * @param   なし
 104:  * @return  string パスワード数
 105: */
 106: function countDictionary() {
 107:     $fsize = (double)filesize(FILENAME_DIC) / DIGEST_LENGTH;
 108: 
 109:     if ($fsize < 10000) {
 110:         $ret = sprintf(" (登録語数:%.0f)", $fsize);
 111:     } else if ($fsize < 100000000) {
 112:         $ret = sprintf(" (登録語数:約%.0f万)", $fsize / 10000);
 113:     } else if ($fsize < 1000000000000) {
 114:         $ret = sprintf(" (登録語数:約%.0f億)", $fsize / 100000000);
 115:     } else {
 116:         $ret = sprintf(" (登録語数:1兆以上)");
 117:     }
 118: 
 119:     return $ret;
 120: }

ユーザー関数 countDictionary は、ブラックリスト辞書にあるパスワード数を求める。
ブラックリスト辞書は固定長バイナリファイルなので、ハッシュ値の長さ DIGEST_LENGTH で除算すれば、登録語数を得られる。
読みやすいように、語数に応じて日本語に変換した文字列を戻す。

解説:JavaScript側

  12: <!DOCTYPE html>
  13: <html lang="ja">
  14: <head>
  15: <meta charset="UTF-8">
  16: <title></title>
  17: <style>
  18: #result {
  19:     font-wqeght: bold;
  20:     color: blue;
  21: }
  22: #error {
  23:     font-wqeght: bold;
  24:     color: red;
  25: }
  26: </style>
  27: 
  28: <!-- jQuery -->
  29: <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.3/jquery.min.js"></script>
  30: <!-- CDN:sprintf関数 -->
  31: <script src="https://cdnjs.cloudflare.com/ajax/libs/sprintf/1.1.2/sprintf.min.js"></script>
  32: <!-- MD5計算ライブラリ (c)mitsunari@cybozu labs -->
  33: <!-- https://labs.cybozu.co.jp/blog/mitsunari/2007/07/md5js_1.html -->
  34: <script src="md5.js"></script>
  35: 
  36: <script>
  37: // 初期値 ==================================================================
  38: //プログラム・タイトル
  39: const TITLE = 'パスワードの強度を調べる(その2)';
  40: //参照サイト
  41: const REFERENCE = 'https://www.pahoo.org/e-soul/webtech/php02/php02-56-11.shtm';
  42: //表示幅(ピクセル)
  43: const WIDTH = 600;
  44: 
  45: //ブラックリスト辞書探索API【passwordStrength2API.phpに合わせて変更すること】
  46: const SEARCH_BLACKLIST_API'https://www.pahoo.org/e-soul/webtech/php02/program/passwordStrength2API.php';
  47: //APIバージョン
  48: const API_VERSION = '1.0.0';
  49: //パスワードの最小長
  50: const PASSWORD_MINIMUM_LENGTH = 8;
  51: //パスワードの最大長
  52: const PASSWORD_MAXIMUM_LENGTH = 64;
  53: //パスワードの初期値
  54: const DEF_QUERY = 'pa$$w0rd';
  55: //パスワード解読に要する時間(1パターンあたり)【変更不可】
  56: const DECODE_TIME = 5.61e-12;   //RTX 4090 https://gigazine.net/news/20240502-nvidia-gpus-solve-password/

クライアントサイドはJavaScriptで記述している。
外部ライブラリとして、APIコールを省力化するために jQuery を(「プログラミング入門 - 7.2 jQueryによる実装」参照)、表示が読みやすいように CDN:sprintf を(「プログラミング入門 - 6.2 書式付き出力」参照)、MD5値を計算するために MD5計算ライブラリ((c)mitsunari@cybozu labs)を利用している。

定数は、【変更不可】としているもの以外は変更可能である。
前述のブラックリスト辞書探索APIを呼び出すURLは SEARCH_BLACKLIST_AP に代入すること。

解説:指定した文字列が数字だけかどうか

  87: /**
  88:  * 指定した文字列が数字だけかどうかを求める.
  89:  * @param   string str 文字列
  90:  * @return  bool true:数字だけである/false:ではない
  91: */
  92: function isNumber(str) {
  93:     let pat = '^[0-9]+$';
  94:     reg = new RegExp(pat);
  95: 
  96:     return (str.match(reg) == null? false : true;
  97: }

ユーザー関数 isNumber は、指定した文字列が数字だけかどうかを求める。正規表現でマッチングさせている。
JavaScriptによる正規表現については、「プログラミング入門 - 5.4 正規表現」をご覧いただきたい。

解説:指定した文字列が英数n文字以下かどうか

  99: /**
 100:  * 指定した文字列が英数字だけかどうかを求める.
 101:  * @param   string str 文字列
 102:  * @return  bool true:英数字だけである/false:ではない
 103: */
 104: function isAlphanumeric(str) {
 105:     let pat = '^[A-Z|a-z|0-9]$';
 106:     reg = new RegExp(pat);
 107: 
 108:     return (str.match(reg) == null? false : true;
 109: }

ユーザー関数 isAlphanumeric は、指定した文字列が英数n文字以下かどうかを求める。正規表現でマッチングさせている。

解説:指定した文字列が辞書に存在するかどうか

 111: /**
 112:  * 指定した文字列が辞書に存在するかどうかを求める.
 113:  * 指定した文字列を小文字に統一して辞書ファイルと比較する.
 114:  * @param   string str 文字列
 115:  * @return  string 辞書にあるパスワード数/null:一致するパスワードはない
 116: */
 117: function inDictionary(str) {
 118:     let s0 = str.toLowerCase()
 119:     let digest = CybozuLabs.MD5.calc(s0);
 120:     let ret   = null;
 121: 
 122:     //WebAPI呼び出し
 123:     $.ajax({
 124:         url:        SEARCH_BLACKLIST_API,
 125:         type:       'POST',
 126:         async:      false,      //非同期通信フラグの指定
 127:         timeout:    1000,       //タイムアウト時間(ミリ秒)
 128:         data: {
 129:             version:    API_VERSION,
 130:             digest:     digest,
 131:         },
 132:     })
 133: 
 134:     //WebAPI接続成功
 135:     .done(function (result) {
 136:         console.log(result);
 137:         let obj = JSON.parse(result);
 138:         //エラー・チェック
 139:         if (obj.error !'') {
 140:             console.error(obj.error);
 141:             $('#error').html('エラー:' + obj.error);
 142:         //一致している
 143:         } else if (obj.result == 'match') {
 144:             //辞書に含まれているパスワード数
 145:             if (obj.words !'') {
 146:                 ret = obj.words;
 147:             } else {
 148:                 ret = '';
 149:             }
 150:         }
 151:     })
 152: 
 153:     //WebAPI接続失敗
 154:     .fail(function (data) {
 155:         console.error(data);
 156:         errmsg = 'WebAPIに接続できません.'
 157:         console.error(errmsg);
 158:         $('#error').html('エラー:' + errmsg);
 159:     });
 160: 
 161:     return ret;
 162: }

ユーザー関数 inDictionary は、指定した文字列が辞書に存在するかどうかを求める。
入力されたパスワードを、上述のMD5計算ライブラリを用いてハッシュ化し、jQuery を用いてAPIを呼び出す。詳しくは、「プログラミング入門 - 7.2 jQueryによる実装」をご覧いただきたい。

解説:指定した文字列が記号を含まないかどうか

 164: /**
 165:  * 指定した文字列が記号を含まないかどうかを求める.
 166:  * @param   string str 文字列
 167:  * @return  bool true:記号が含んでいない/false:含んでいる
 168: */
 169: function isContainNoSymbol(str) {
 170:     let pat = '^[A-Z|a-z|0-9]+$';
 171:     reg = new RegExp(pat);
 172: 
 173:     return (str.match(reg) == null? false : true;
 174: }

ユーザー関数 isContainNoSymbol は、指定した文字列が記号を含まないかどうかを返す。正規表現でマッチングさせている。

解説:指定した文字列に連続した文字を含むかどうか

 176: /**
 177:  * 指定した文字列に連続した文字を含むかどうかを求める.
 178:  * @param   string str 文字列
 179:  * @return  bool true:連続した文字を含む/false:含まない
 180: */
 181: function isSeqCharacters(str) {
 182:     let ret = false;
 183:     let len = str.length;
 184:     if (len > 1) {
 185:         for (let i = 1i < leni++) {
 186:             if (str[i] == str[i - 1]) {
 187:                 ret = true;
 188:                 break;
 189:             }
 190:         }
 191:     }
 192:     return ret;
 193: }

ユーザー関数 isSeqCharacters は、指定した文字列に連続した文字を含むかどうかを返す。文字列を配列と見なして、直前の文字と一致するかどうかをチェックしている。

解説:総当たりで解読するときの時間

 195: /**
 196:  * 指定したパスワードを総当たりで解読するときの時間を求める.
 197:  * @param   string psw パスワード
 198:  * @return  string 解読時間
 199: */
 200: function calcDecodeTime(psw) {
 201:     //文字種と桁数を求める.
 202:     let num = 0
 203:     let len = 0;
 204:     if (psw.match(/^[0-9]+$/!null) {
 205:         num = 10;
 206:     } else if (psw.match(/^[A-Z]+$/!null) {
 207:         num = 26;
 208:     } else if (psw.match(/^[0-9|A-Z]+$/!null) {
 209:         num = 36;
 210:     } else if (psw.match(/^[a-z]+$/!null) {
 211:         num = 26;
 212:     } else if (psw.match(/^[0-9|a-z]+$/!null) {
 213:         num = 36;
 214:     } else if (psw.match(/^[A-Z|a-z]+$/!null) {
 215:         num = 52;
 216:     } else if (psw.match(/^[0-9|A-Z|a-z]+$/!null) {
 217:         num = 62;
 218:     } else {
 219:         num = 86;
 220:     }
 221:     len = psw.length;
 222: 
 223:     //解読時間を計算する.
 224:     let ret = '';
 225:     let sec = Math.pow(num, len* DECODE_TIME;
 226:     if (sec <1) {
 227:         ret = sprintf('1秒以下で解読できる.');
 228:     } else if (sec < 60) {
 229:         ret = sprintf('解読に約%.0f秒かかる.', sec);
 230:     } else if (sec < 60 * 60) {
 231:         ret = sprintf('解読に約%.0f分かかる.', sec / 60);
 232:     } else if (sec < 60 * 60 * 24) {
 233:         ret = sprintf('解読に約%.0f時間かかる.', sec / (60 * 60));
 234:     } else if (sec < 60 * 60 * 24 * 30) {
 235:         ret = sprintf('解読に約%.0f日かかる.', sec / (60 * 60 * 24));
 236:     } else if (sec < 60 * 60 * 24 * 30 * 12) {
 237:         ret = sprintf('解読に約%.0fヶ月かかる.', sec / (60 * 60 * 24 * 30));
 238:     } else if (sec < 60 * 60 * 24 * 30 * 12 * 1000) {
 239:         ret = sprintf('解読に約%.0f年かかる.', sec / (60 * 60 * 24 * 30 * 12));
 240:     } else {
 241:         ret = sprintf('解読に1000年以上かかる.');
 242:     }
 243: 
 244:     return ret;
 245: }

ユーザー関数 calcDecodeTime は、指定したパスワードを総当たりで解読するときの時間を求める。
使用されている文字種の数を変数 num に、パスワード長を変数 len に格納し、numlen 乗が総当たりパターン数となる。これに1パターンあたりの解読時間 DECODE_TIME を乗ずることで解読時間(秒)を求める。NVIDIA製GPU「RTX 4090」1基で、英小文字8文字からなるパスワードを5分で解読できるという記事(NVIDIAの高性能グラボは複雑なパスワードも短時間で突破可能 GIGAZINE, 2024年5月2日)より、
\[ \displaystyle DECODE\_TIME = \frac{5 \times 60}{(26 \times 2) ^ 8} \]
と算出した。
この関数は、結果を読みやすいように日本語文字列に変換して戻す。

解説:パスワードの強度を求める

 247: /**
 248:  * パスワードの強度を求める.
 249:  * 強度は1~5の整数で,数字が大きいほど強度が強い.
 250:  *              1:数字のみ
 251:  *              2:英数n文字以下
 252:  *              3:ブラックリスト辞書に存在する
 253:  *              4:1~3をクリアし,記号が含まれていない
 254:  *              5:1~3をクリアし,記号が含まれている
 255:  *              6:1~5をクリアし,連続した文字がない
 256:  * @param   なし
 257:  * @return  なし
 258: */
 259: function getPasswordStrength() {
 260:     //結果をクリアすいる.
 261:     $('#result').html('');
 262:     //エラーメッセージをクリアする.
 263:     $('#error').html('');
 264: 
 265:     //入力したパスワード
 266:     let psw = $('#psw').val();
 267:     //空白除去
 268:     psw = psw.trim();
 269: 
 270:     //パスワードの強度を求める
 271:     let ret = '不明';
 272:     if ((psw.length < PASSWORD_MINIMUM_LENGTH|| (psw.length > PASSWORD_MAXIMUM_LENGTH)) {
 273:         ret = sprintf('強度0:%d文字未満,または%d文字超の長さ.', PASSWORD_MINIMUM_LENGTH, PASSWORD_MAXIMUM_LENGTH);
 274:     } else if (isNumber(psw)) {
 275:         ret = '強度1:数字のみ.';
 276:     } else if (isAlphanumeric(psw, PASSWORD_MINIMUM_LENGTH)) {
 277:         ret = '強度2:英数字のみ.';
 278:     } else if ((ss = inDictionary(psw)) !null) {
 279:         ret = '強度3:ブラックリスト辞書に存在する.' + ss;
 280:     } else if ($('#error').html() !'') {      //APIエラー
 281:         return;
 282:     } else if (isContainNoSymbol(psw)) {
 283:         ret = '強度4:ブラックリスト辞書に存在せず,記号を含まない.';
 284:     } else if (isSeqCharacters(psw)) {
 285:         ret = '強度5:ブラックリスト辞書に存在せず,記号を含んでいる.';
 286:     } else {
 287:         ret = '強度6:ブラックリスト辞書に存在せず,記号を含んでおり,連続する文字はない.';
 288:     }
 289: 
 290:     //解読時間を求める.
 291:     ret = ret + "<br />" + calcDecodeTime(psw);
 292: 
 293:     $('#result').html(ret);
 294: }

ユーザー関数 getPasswordStrength は、これまでの関数を利用し、パスワードの強度を求める。

参考サイト

(この項おわり)
header