目次
- サンプル・プログラム
- 使用ライブラリ
- リソースの準備
- プログラム製作の背景
- 解説:指定した文字列が数字だけかどうか
- 解説:指定した文字列が英数n文字以下かどうか
- 解説:指定した文字列が辞書に存在するかどうか
- 解説:指定した文字列が記号を含まないかどうか
- 解説:指定した文字列に連続した文字を含むかどうか
- 解説:総当たりで解読するときの時間
- 解説:パスワードの強度を求める
- 解説:指定した文字列が数字だけかどうか
- 解説:辞書ファイルを空にする
- 解説:辞書ファイルを指定したメモリに読み込む
- 解説:指定したハッシュ値が指定したメモリになければ追加する
- 解説:指定したメモリの内容を辞書ファイルに書き込む
- 解説:ブラックリスト・ファイルを読み込んで辞書ファイルに追加
- 解説:辞書ファイルに登録されているパスワード数
- 同梱のブラックリスト辞書について
- 参考サイト
サンプル・プログラム
passwordStrength.msi | インストーラ |
bin/passwordStrength.exe | 実行プログラム本体(GUI版) |
bin/pswst.exe | 実行プログラム本体(CUI版) |
bin/etc/help.chm | ヘルプ・ファイル |
sour/passwordStrength.cpp | ソース・プログラム |
sour/resource.h | リソース・ヘッダ |
sour/resource.rc | リソース・ファイル(GUI版) |
sour/resource2.rc | リソース・ファイル(CUI版) |
sour/application.ico | アプリケーション・アイコン(GUI版) |
sour/application2.ico | アプリケーション・アイコン(CUI版) |
sour/passwords.txt | ブラックリスト辞書(サンプル) |
sour/makefile | GUI版ビルド |
sour/makefile_cmd | CUI版ビルド |
バージョン | 更新日 | 内容 |
---|---|---|
1.0.2 | 2023/11/23 | 辞書ファイルを強化,使用ライブラリ更新 |
1.0.1 | 2023/07/23 | 使用ライブラリをバージョンアップ,bug-fix |
1.0.0 | 2023/02/19 | 初版 |
使用ライブラリ
リソースの準備
ResEdit を起動し、resource.rc を用意する。
Eclipse に戻り、ソース・プログラム "passwordStrength.cpp" を追加する。
リンカー・フラグを -s -mwindows -static -lstdc++ -lgcc -lole32 -lcrypto -lboost_program_options-mt -lpthread -lwinpthread" に設定する。
また、CUI版をビルドするために、構成 CMD を追加し、リンカー・フラグを -s -static -lstdc++ -lgcc -lole32 -lcrypto -lboost_program_options-mt -lpthread に設定する。
MSYS2 コマンドラインからビルドするのであれば、"makefile" と "makefile_cmd" を利用してほしい。
プログラム製作の背景
NIST(National Institute of Standards and Technology;米国立標準技術研究所)は、セキュリティ文書「NIST Special Publication 800-63B」の「5.1.1 記憶シークレット」に、パスワードの要件として次の項目を挙げている。
- 長いパスワード(8文字以上、最長64文字)
- 表示可能文字のASCII,Unicodeや空白の使用を許可
- Password1やqwerty123などの違反したパスワード、辞書単語をブラックリストに設定
- aaaa1234や123456などの連続した同じ文字の使用を制限
- 強固なパスワードチェッカーを使用
- 連続で認証失敗した場合に強制的にアカウントをロック
- パスワードの入力でペースト機能の使用を許可
- パスワードの他、別の種類による二要素認証を強制
この要件の1~4を実装した「5.パスワードチェッカー」を作ろうと考えた。
解説:指定した文字列が数字だけかどうか
537: /**
538: * 指定した文字列が数字だけかどうかを求める.
539: * @param string str 文字列
540: * @return bool TRUE:数字だけである/FALSE:ではない
541: */
542: bool isNumbersOnly(string str) {
543: regex re("^[0-9]+$");
544: return regex_match(str, re);
545: }
解説:指定した文字列が英数字だけかどうか
547: /**
548: * 指定した文字列が英数字だけかどうかを求める.
549: * @param string str 文字列
550: * @return bool TRUE:英数字だけである/FALSE:ではない
551: */
552: bool isAlphanumeric(string str) {
553: regex re("^[A-Z|a-z|0-9]+$");
554: return regex_match(str, re);
555: }
解説:指定した文字列が辞書に存在するかどうか
557: /**
558: * 指定した文字列が辞書に存在するかどうかを求める.
559: * 指定した文字列を小文字に統一して辞書ファイルと比較する.
560: * @param string str 文字列
561: * @return bool TRUE:存在する/FALSE:存在しない、またはエラー
562: */
563: bool inDictionary(string str) {
564: unsigned char s1[SIZE_BUFF + 1];
565: unsigned char s2[SIZE_BUFF + 1];
566:
567: string s0 = str;
568: //小文字に統一する
569: transform(s0.begin(), s0.end(), s0.begin(), ::tolower);
570: size_t len = s0.length();
571: //MD5ハッシュ値をs1に代入する.
572: MD5((const unsigned char *)s0.c_str(), len, (unsigned char *)s1);
573:
574: // printDictionary((Digest *)s1);
575: // cout << endl;
576:
577: //カーソルを砂時計に
578: HCURSOR cur = SetCursor(LoadCursor(NULL, IDC_WAIT));
579:
580: //辞書ファイルに含まれているかどうか探す.
581: bool ret = FALSE;
582: const char *dicname = getDictionaryName();
583: FILE *infp = fopen(dicname, "rb");
584: if (infp == NULL) {
585: ErrorMessage = "辞書ファイルが見当たりません";
586: cout << ErrorMessage << endl;
587: return FALSE;
588: }
589: for (size_t i = 0; i < MAX_WORDS; i++) {
590: if (feof(infp)) break;
591: fread(s2, sizeof(Digest), 1, infp);
592:
593: // printDictionary((Digest *)s2);
594: // cout << endl;
595:
596: if (memcmp(s1, s2, sizeof(Digest)) == 0) {
597: ret = TRUE;
598: break;
599: }
600: }
601: fclose(infp);
602:
603: //カーソルを元に戻す
604: SetCursor(cur);
605:
606: return ret;
607: }
入力されたパスワードを、MD5でハッシュ化し、ブラックリスト辞書にあるかどうかを突合する。ブラックリスト辞書は固定長バイナリファイルなので、forループで回して合致するハッシュ値があるかどうかを調べている。
解説:指定した文字列が記号を含まないかどうか
609: /**
610: * 指定した文字列が記号を含むかどうかを返す.
611: * @param string str 文字列
612: * @return bool TRUE:記号を含んでいない/FALSE:含んでいる
613: */
614: bool isContainNoSymbol(string str) {
615: regex re("^[A-Z|a-z|0-9]+$");
616: return regex_match(str, re);
617: }
解説:指定した文字列に連続した文字を含むかどうか
619: /**
620: * 指定した文字列に連続した文字を含むかどうかを求める.
621: * @param string str 文字列
622: * @return bool true:連続した文字を含む/false:含まない
623: */
624: bool isSeqCharacters(string str) {
625: const char *ptr = str.c_str();
626: bool ret = FALSE;
627: size_t len = str.length();
628: if (len > 1) {
629: for (size_t i = 1; i < len; i++) {
630: if (ptr[i] == ptr[i - 1]) {
631: ret = TRUE;
632: break;
633: }
634: }
635: }
636: return ret;
637: }
解説:総当たりで解読するときの時間
639: /**
640: * 指定したパスワードを総当たりで解読するときの時間を求める.
641: * @param string psw パスワード
642: * @return string 解読時間
643: */
644: string calcDecodeTime(string psw) {
645: //文字種と桁数を求める.
646: regex re1("^[0-9]+$");
647: regex re2("^[A-Z]+$");
648: regex re3("^[0-9|A-Z]+$");
649: regex re4("^[a-z]+$");
650: regex re5("^[0-9|a-z]+$");
651: regex re6("^[A-Z|a-z]+$");
652: regex re7("^[0-9|A-Z|a-z]+$");
653: size_t num, len;
654: if (regex_match(psw, re1)) {
655: num = 10;
656: } else if (regex_match(psw, re2)) {
657: num = 26;
658: } else if (regex_match(psw, re3)) {
659: num = 36;
660: } else if (regex_match(psw, re4)) {
661: num = 26;
662: } else if (regex_match(psw, re5)) {
663: num = 36;
664: } else if (regex_match(psw, re6)) {
665: num = 52;
666: } else if (regex_match(psw, re7)) {
667: num = 62;
668: } else {
669: num = 86;
670: }
671: len = psw.length();
672:
673: //解読時間を計算する.
674: static char buff[SIZE_BUFF + 1];
675: double sec = (double)pow(num, len) * DECODE_TIME;
676: if (sec <= 1) {
677: snprintf(buff, sizeof(buff), "1秒以下で解読できる.");
678: } else if (sec < 60) {
679: snprintf(buff, sizeof(buff), "解読に約%.0f秒かかる.", sec);
680: } else if (sec < (double)60 * 60) {
681: snprintf(buff, sizeof(buff), "解読に約%.0f分かかる.", sec / 60);
682: } else if (sec < (double)60 * 60 * 24) {
683: snprintf(buff, sizeof(buff), "解読に約%.0f時間かかる.", sec / (60 * 60));
684: } else if (sec < (double)60 * 60 * 24 * 30) {
685: snprintf(buff, sizeof(buff), "解読に約%.0f日かかる.", sec / (60 * 60 * 24));
686: } else if (sec < (double)60 * 60 * 24 * 30 * 12) {
687: snprintf(buff, sizeof(buff), "解読に約%.0fヶ月かかる.", sec / (60 * 60 * 24 * 30));
688: } else if (sec < (double)60 * 60 * 24 * 30 * 12 * 1000) {
689: snprintf(buff, sizeof(buff), "解読に約%.0f年かかる.", sec / ((double)60 * 60 * 24 * 30 * 12));
690: } else {
691: snprintf(buff, sizeof(buff), "解読に1000年以上かかる.");
692: }
693:
694: return (string)buff;
695: }
使用されている文字種の数を変数 num に、パスワード長を変数 len に格納し、num の len 乗がが総当たりパターン数となる。これに1パターンあたりの解読時間 DECODE_TIME を乗ずることで解読時間(秒)を求める。
結果を読みやすいように日本語文字列に変換して戻す。
解説:パスワードの強度を求める
697: /**
698: * 指定したパスワードの強度を求める.
699: * 強度は1〜5の整数で,数字が大きいほど強度が強い.
700: * @param string psw パスワード
701: * @param size_t min パスワードの最小長;省略時はPASSWORD_MINIMUM_LENGTH
702: * @param size_t max パスワードの最大長;省略時はPASSWORD_MAXIMUM_LENGTH
703: * @return int 強度
704: * 1:数字のみ
705: * 2:英数n文字以下
706: * 3:ブラックリスト辞書に存在する
707: * 4:1〜3をクリアし,記号が含まれていない
708: * 5:1〜3をクリアし,記号が含まれている
709: * 6:1〜5をクリアし,連続した文字がない
710: */
711: int getPasswordStrength(string psw, size_t min=PASSWORD_MINIMUM_LENGTH, size_t max=PASSWORD_MAXIMUM_LENGTH) {
712: //空白を除く
713: string str = regex_replace(psw, regex("[ \\t]+"), "");
714:
715: //パスワードstrの強度を算出する.
716: int ret = 0;
717: if ((psw.length() < min) || (psw.length() > max)) {
718: ret = 0;
719: } else if (isNumbersOnly(str)) {
720: ret = 1;
721: } else if (isAlphanumeric(str)) {
722: ret = 2;
723: } else if (inDictionary(str)) {
724: ret = 3;
725: } else if (isContainNoSymbol(str)) {
726: ret = 4;
727: } else if (isSeqCharacters(str)) {
728: ret = 5;
729: } else {
730: ret = 6;
731: }
732: return ret;
733: }
解説:辞書ファイル名を求める
287: /**
288: * 辞書ファイル名を求める.
289: * @param なし
290: * @return string 辞書ファイル名
291: */
292: const char *getDictionaryName(void) {
293: static char buff[SIZE_BUFF + 1];
294: strncpy(buff, (getMyPath(APPNAME) + FILENAME_DIC).c_str(), SIZE_BUFF);
295: return (const char *)buff;
296: }
まず、ユーザー関数 getDictionaryName は、ブラックリスト辞書ファイルへのフルパスを求める。ユーザーのAppDataの下にある。
解説:辞書ファイルを空にする
312: /**
313: * 辞書ファイルを空にする.
314: * @param なし
315: * @return bool TRUE:成功/FALSE:失敗
316: */
317: bool emptyDictionary() {
318: const char *outfname = getDictionaryName();
319: FILE *outfp;
320: outfp = fopen(outfname, "wb");
321: fclose(outfp);
322:
323: return TRUE;
324: }
解説:辞書ファイルを指定したメモリに読み込む
326: /**
327: * 辞書ファイルを指定したメモリに読み込む.
328: * @param unsigned char *dic 読み込むメモリ
329: * @return size_t 読み込んだ見出語の数
330: */
331: size_t readDictionary(unsigned char *dic) {
332: size_t cnt;
333: const char *infname = getDictionaryName();
334: FILE *infp;
335: infp = fopen(infname, "rb");
336: if (infp == NULL) {
337: ErrorMessage = "辞書ファイルが見つかりません";
338: return 0;
339: }
340:
341: //辞書カウンタを初期化してメモリに読み込む.
342: DictionaryCounter = 0;
343: for (cnt = 0; cnt < MAX_WORDS; cnt++) {
344: if (feof(infp)) break;
345: fread(dic + cnt * sizeof(Digest), sizeof(Digest), 1, infp);
346: DictionaryCounter++;
347: }
348: fclose(infp);
349:
350: return cnt;
351: }
解説:指定したハッシュ値が指定したメモリになければ追加する
371: /**
372: * 指定したハッシュ値が指定したメモリになければ追加する.
373: * 直近に登録した50万件に重複がなければ追加する.
374: * @param unsigned char *dic ハッシュ値格納メモリ
375: * @param Digest *md5 ハッシュ値
376: * @return bool TRUE:追加成功/FALSE:失敗
377: */
378: bool addDictionary(unsigned char *dic, Digest *md5) {
379: static Digest zero;
380: memset((void *)&zero, 0, sizeof(Digest));
381: size_t i;
382: static unsigned char *pointer;
383: static size_t ll = sizeof(Digest);
384: size_t cnt;
385:
386: //BACK_WORDS語だけ遡って重複チェックする.
387: if (DictionaryCounter > BACK_WORDS) {
388: cnt = DictionaryCounter - BACK_WORDS;
389: pointer = dic + cnt * ll;
390: //それ以外
391: } else {
392: cnt = 0;
393: pointer = dic;
394: }
395:
396: //指定したハッシュ値が指定したメモリになければ追加する.
397: for (i = cnt; i < MAX_WORDS; i++) {
398: if (memcmp(pointer, (void *)&zero, ll) == 0) {
399: break;
400: } else if (memcmp(pointer, (void *)md5, ll) == 0) {
401: return FALSE;
402: }
403: pointer += ll;
404: }
405:
406: //登録可能かどうかを検査する.
407: if (i >= MAX_WORDS) {
408: ErrorMessage = "辞書に登録できない";
409: return FALSE;
410: } else {
411: memcpy(dic + i * sizeof(Digest), (void *)md5, sizeof(Digest));
412: DictionaryCounter++;
413: return TRUE;
414: }
415: }
このとき、BACK_WORDS語だけ遡って重複チェックし、同じハッシュ値を見つけたら追加しないようにした。本当は銭湯に戻って重複チェックをしたかったのだが、Wikipedia見出しのように1千万語超になると重複チェックがボトルネックとなり、ブラックリスト辞書の追加に1日以上かかることから、このような処理にした。
解説:指定したメモリの内容を辞書ファイルに書き込む
353: /**
354: * 指定したメモリの内容を辞書ファイルに書き込む.
355: * @param unsigned char *dic 書き込むメモリ
356: * @param size_t cnt 見出し語の数
357: * @return bool TRUE:書き込み成功/FALSE:失敗
358: */
359: bool writeDictionary(unsigned char *dic, size_t cnt) {
360: const char *outfname = getDictionaryName();
361: FILE *outfp;
362: outfp = fopen(outfname, "wb");
363: for (size_t i = 0; i < cnt; i++) {
364: fwrite(dic + i * sizeof(Digest), sizeof(Digest), 1, outfp);
365: }
366: fclose(outfp);
367:
368: return TRUE;
369: }
解説:ブラックリスト・ファイルを読み込んで辞書ファイルに追加
417: /**
418: * ブラックリスト・ファイルを読み込んで,辞書ファイルに追加する.
419: * @param const char* infname パスワード・ファイル名
420: * @param unsigned column 読み込むカラム番号
421: * @return size_t 読み込んだ見出語の数
422: */
423: size_t readKeywords2Dictionary(const char* infname, unsigned column) {
424: //カーソルを砂時計に
425: HCURSOR cur = SetCursor(LoadCursor(NULL, IDC_WAIT));
426:
427: //辞書読み込み用のメモリを確保する。
428: unsigned char *dic;
429: dic = (unsigned char *)malloc((MAX_WORDS + 1) * sizeof(Digest));
430: memset(dic, 0, (MAX_WORDS + 1) * sizeof(Digest));
431:
432: //辞書ファイルをメモリに読み込む.
433: if (readDictionary(dic) == FALSE) {
434: return FALSE;
435: }
436:
437: //ブラックリスト・ファイルを読み込みオープンする.
438: static char s1[SIZE_BUFF + 1];
439: static char s2[SIZE_BUFF + 1];
440: FILE *infp;
441: infp = fopen(infname, "rb");
442: if (infp == NULL) {
443: ErrorMessage = (string)infname + " の読み込みに失敗しました";
444: fclose(infp);
445: return FALSE;
446: }
447:
448: //パスワードを1つずつ読み込み,ハッシュ値に変換して辞書ファイルへ追加する.
449: size_t cnt = 0;
450: size_t cnt2 = 0;
451: size_t len = 0;
452: while (cnt2 < MAX_WORDS) {
453: if (feof(infp)) break;
454: if (fgets(s1, SIZE_BUFF, infp) == NULL) break;
455: len = strlen(s1);
456: int k = 0;
457: unsigned col = 1; //カラム番号
458: for (size_t j = 0; j < len; j++) {
459: //カラム区切り文字を見つけたら,カラム番号を1つ増やす.
460: //読み込むカラム番号を超えたら読み込みを打ち切る.
461: if ((s1[j] == '\t') || (s1[j] == ' ') || (s1[j] == ',')) {
462: col++;
463: if (col > column) {
464: break;
465: }
466: k = 0;
467: //英数記号を取り込む.
468: } else if (col == column) {
469: if ((s1[j] >= '!') && (s1[j] <= '~')) {
470: //小文字に統一する.
471: s2[k] = tolower(s1[j]);
472: k++;
473: }
474: }
475: }
476: s2[k] = '\0';
477:
478: //最小長より長ければ辞書に登録する.
479: static Digest dd;
480: if (k >= PASSWORD_MINIMUM_LENGTH) {
481: //MD5ハッシュ値をddに代入する.
482: MD5((const unsigned char *)s2, k, (unsigned char *)&dd);
483:
484: // cout << s2 << endl;
485: // printDictionary((Digest *)dd);
486: // cout << endl;
487:
488: //読み込みカウンタ
489: if (cnt % 10000 == 0) {
490: cout << cnt << "=>" << cnt2 << '\r';
491: }
492: if (addDictionary(dic, &dd) == TRUE) {
493: cnt2++;
494: }
495: cnt++;
496: }
497: }
498: fclose(infp);
499:
500: //辞書ファイルへ書き込む.
501: writeDictionary(dic, DictionaryCounter);
502: cout << "\nDictionaryCounter = " << DictionaryCounter << endl;
503: //メモリを解放する.
504: free(dic);
505:
506: //カーソルを元に戻す
507: SetCursor(cur);
508:
509: return cnt2;
510: }
ブラックリスト・ファイルはテキストファイルで、1行に1パスワードが記載されているものとする。タブ、空白、カンマでカラムが区切られていてもよく、その場合は、columnにパスワードがあるカラム番号(左端の先頭が1)を指定してやる。
解説:辞書ファイルに登録されているパスワード数
512: /**
513: * 辞書ファイルに登録されているパスワード数を求める.
514: * パスワード数は"1234","約10万"のような文字列で返す.
515: * @param なし
516: * @return string パスワード数
517: */
518: string countDictionary(void) {
519: const char *infname = getDictionaryName();
520: double fsize = filesystem::file_size((string)infname) / sizeof(Digest);
521: char buff[SIZE_BUFF + 1];
522:
523: if (fsize < 10000) {
524: snprintf(buff, SIZE_BUFF, " (登録語数:%.0f)", fsize);
525: } else if (fsize < 100000000) {
526: snprintf(buff, SIZE_BUFF, " (登録語数:約%.0f万)", fsize / 10000);
527: } else if (fsize < 1000000000000) {
528: snprintf(buff, SIZE_BUFF, " (登録語数:約%.0f億)", fsize / 100000000);
529: } else {
530: snprintf(buff, SIZE_BUFF, " (登録語数:1兆以上)");
531: }
532:
533: return buff;
534: }
ブラックリスト辞書は固定長バイナリファイルなので、ハッシュ値の長さ DIGEST_LENGTH で除算すれば、登録語数を得られる。
読みやすいように、語数に応じて日本語に変換した文字列を戻す。
同梱のブラックリスト辞書について
- Wikipedia英語版 見出し語
- zxcvbn:Dropbox,2015年11月
- 使ってはいけないパスワードトップ10万:英NCSC,2019年4月
- 10-million-password-list-top-1000000.txt:Daniel Miessler,2020年7月
参考サイト
- PHPでパスワードの強度を調べる:ぱふぅ家のホームページ
- PHPでパスワードの強度を調べる(その2):ぱふぅ家のホームページ
- WiX によるWindowsインストーラー作成:ぱふぅ家のホームページ
- C++ 開発環境の準備:ぱふぅ家のホームページ
- NIST Special Publication 800-63B:NIST
(2023年11月23日)辞書ファイルを強化,使用ライブラリ更新
(2023年7月30日)使用ライブラリをバージョンアップ,不具合修正