がるの健忘録

エンジニアでゲーマーで講師で占い師なおいちゃんのブログです。

「そうだパスワードの持ち方、変えよう」

色々あっていろいろあったのに触発されました(笑
いや昔実際に実務で実装したこともあったんで、その辺を踏まえて。

大前提として
・ログインロジックに介入できる
・ユーザの「最終ログイン時間」が捕捉できる(ケースによる)
が必要になりますんでご注意を。
最終ログイン時間については「今からそのロジック追加」で間に合います。

次。
用語整理軽く。

Salt(ソルト)
hash時の味付け。詳しくは「salt hash」あたりでググってください。
この記事では勝手に「ユーザ個別のもの」をsaltって言っておきます(いちいち書くのが面倒なので)。
「共通のsalt」は、「共通salt」と明記しておきます。

Pepper(SecretSalt)(ペッパー、シークレットソルト)
これも「ペッパー hash」でググっていただくのが早いかと。

もういっちょ。
ログインのロジックの、以下に介入する前提です。だいたいイメージで察してください。
ベースになるコードイメージです。
端的には「idに対応するレコードがなければfalse、入力パスワードと保存パスワード比較して不一致ならfalse。idがokでパスワードもokならtrue」って感じ。
「そっくりそのまんま」とまでは言わんにしても、まぁ近似値な処理がきっとある、はず!!(笑

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // hashの突合せ  
    if (hash値の比較($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    // OKだったぽいので認証通る  
    return true;  
}  

ほんでもって次。
パスワードを「どのように保存しているか」は、たぶん、こんなグラデーションなんではないかなぁ、と。
(ペッパーの有無はいったん省きます。リストが2倍になるの面倒なんで)

  • 平文
  • md5
  • 共通salt付きmd5(ストレッチあったりなかったり
  • 個別salt付き適当なhash(ストレッチあったりなかったり
  • Modular Crypt Formatで適切なやつ(古くなったhashアルゴリズム)
  • Modular Crypt Formatで適切なやつ(良い感じのhashアルゴリズム)

さて一通り出そろいましたので、入れ替えを見ていきましょう。

MCFなんだけどアルゴリズム古し

今が「Modular Crypt Formatで適切なやつ(良い感じのhashアルゴリズム)」なら別段なにもせんでえぇので。
1ランク下がりました「Modular Crypt Formatで適切なやつ(古くなったhashアルゴリズム)」である場合を考えてみましょう。

古いんで新しいのに差し替えたいです。
おおざっぱには

  • (古いのだったら)新しいアルゴリズムのに差し替える
  • 適当に時間がたったら「パスワードhashのアルゴリズム」確認して、差し替え前アルゴリズムなら、一度パスワードのレコードを空文字とかにする

ってな感じになります。

実装。
ちょいと丁寧にすると、だいたいまぁこんな感じか、と。
password_hash()使ってる前提です。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // hashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    /* OKだったぽいので */  
    // hashのアルゴリズムが古かったら、新しいのに差し替える  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション) WHERE userを特定する情報;  
    }  

    // 認証通る  
    return true;  
}  

こんな感じ。
これで「ログイン通過してhashが古い」人は、新しいhashに書き換わります。

……そこから幾星霜が経過しました(長い)。

そろそろ「古いhash、本格的に撲滅しねぇ?」って感じの頃合いでございます。それだけ時間がたったんだってば。
こんなbatchを組んでみましょう。

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    $hashed_pw = $uのpasswordhash取得();  
    // hashのアルゴリズムが古かったら、パスワードいったん消す  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = '' WHERE userを特定する情報($uにあるはず);  
        なんかログに残したり表示したり  
    }  
}  

これで「古いhashは撲滅」できます。
後は、ログのuser_idでもいいし、改めてDBからselectしてもいいし、のターゲットに対して
「しばらくログインがなかったからパスワード無効にしたのでパスワードリマインダで設定してから再ログインしてね」
とかって連絡をすれば、一段落。

MCFじゃなかったんだけどまぁそこまでクリティカルにマズい持ち方してない

次。
「個別salt付き適当なhash(ストレッチあったりなかったり」を想定してみましょう。
他よりはましなのですが、MCFではないので色々と面倒が付きまといます。
サクっと本体実装を見ていきましょう。
大まかには

  • MCFで認証通ったらそのまま
  • 古いhash方式で認証通ったら「MCFに置き換えつつ」認証OK
  • そうじゃなきゃNG

って感じですね。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列と個別のsalt」を取得。ストレッチ回数は「ここに入れてるけど多分どこかglobalな領域にあるんじゃね?」を想定  
    $hashed[hadhed_password, 個別のsalt, ストレッチ回数] = XXXX();  
    if ($hashedが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    /* MCFでのhashの突合せ */  
    if (password_verify($raw_password, $hashed['hadhed_password']) === true) {  
        // 新しい方式で認証通ったのでOK  
        return true;  
    }  

    /* 古い方式での認証突合せ */  
    // raw_passwordをhash化する  
    $in_hashed_password = salt付けてストレッチしてハッシュするメソッド($raw_password, $hashed[個別のsalt], $hashed[ストレッチ回数]);  
    // hashされた値の比較  
    if (hash_equals($in_hashed_password, $hashed['hadhed_password']) === true) {  
        // hashのアルゴリズムが古かったら、新しいのに差し替える  
        $password_info = password_get_info($hashed_pw);  
        if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
            // 別に個別saltは空にしなくてもいいんだけど、一応  
            update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション), 個別salt='' WHERE userを特定する情報;  
        }  
        // 認証通る  
        return true;  
    }  

    // 認証NG  
    return false;  
}  

これでまたしばらくしたら「古い方式を無効にする」ので。
「幾星霜」よりもうちょっと短めのほうがいいかも、ではあるけど(早めにロジック掃除したいじゃん?)まぁ任意。
同じバッチを組めばOK。

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    $hashed_pw = $uのpasswordhash取得();  
    // hashのアルゴリズムが古かったら、パスワードいったん消す  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = '' WHERE userを特定する情報;  
        なんかログに残したり表示したり  
    }  
}  

$password_info['algoName']、たしか「unknown」だかなんだか、になったはず(うろ覚え)。
あと、このタイミングで「古いhashでの認証処理ロジックを削除」「個別saltのカラムを削除」「ストレッチ回数の情報の破棄」とかやってもよいですね。

MCFじゃなかったしクリティカルにマズい持ち方してる

さて、残るは

・平文
md5
・共通salt付きmd5(ストレッチあったりなかったり

というゴツ目なあたり。
いや身もふたもない「即時に片付く」方法が一応ありまして。

・ログインのロジック(とか周辺あちこち)を「MCFのパスワード」に置き換える(ステージングくらいまでで確認)
・メンテナンスに入る
・プログラムをデプロイ
・DBのパスワードカラムを全部「空文字」でupdateする
・メンテナンス終わり
・全ユーザに「ごめんパスワードリマインダからパスワード設定しなおして」の連絡をする

と、あら不思議「脆弱なパスワードがなくなります」(笑
いやマジなところ、上3つならコレやったほうがいいような気がするんですけどねぇ。
一度、マジで提案してみましょう。通ったらお互いに「少しだけ」幸せになれるwww

でも「ど~しても難しい」場合、以下の手順を踏むとまぁ「ギリ」かなぁ、と。
「明日、クラックされた」を前提にする分、ちょいと内部的には手間でございます。

「以下のコードに修正する」と「DBに細工をする」を同じタイミングで流す(メンテナンスのin-outで対応しましょう)

DB小細工。これによって「とりあえずパスワードを「MCFフォーマット(なので最悪漏れても時間稼ぎが出来る状態)」に置き換える

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    update ユーザテーブル set password = password_hash($u->password, DEFAULTなりお好きなアルゴリズムなり, オプションもお好みで) WHERE $u->user_id;  
}  

次、認証側のコード。
入っているパスワードが「平文」の場合、ある意味気楽で。
以下のコードでいけます。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        return false;  
    }  

    // hashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    // OKだったぽいので認証通る  
    return true;  
}  

md5だったり「共通salt付きmd5」だったりするときは、こんなふうにしましょう。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashedが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // MCFでのhashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === true) {  
        // 新しい方式で認証通ったのでOK  
        return true;  
    }  

    /* 古い方式での認証突合せ */  
    // raw_passwordを(以前のやり方で)hash化する(共通saltとストレッチ回数があるパターン。ない時は引数調整して)  
    $old_hashed_password = salt付けてストレッチしてハッシュするメソッド($raw_password, 共通salt, ストレッチ回数);  

    // 「古いやり方でhash化した」パスワードを「生パスワード」と仮定して、値の比較  
    if (password_verify($old_hashed_password, $hashed_pw) === true) {  
        // 本来の正しいのに置き換える  
        update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション) WHERE userを特定する情報;  
          
        // 認証通る  
        return true;  
    }  

    // 認証NG  
    return false;  
}  

これで多分そのうちなんとか。
最悪漏れてもこれなら「普通にちゃんとセキュリティ担保したパスワードとして有効な保持の仕方」なので、どうにかなるでしょ。

で……この方式のみ「切り替わってるんだか切り替わってないんだかわからない」ので。
もし「古いロジック残しておいても面倒だから適当なタイミングで"更新していないユーザのパスワード"リセットしたい」のであれば、最終ログイン時間を使って「上にあるバッチ」で「最終ログイン時間が一定以上過去の人のパスワードを空文字にする」でやればOK。

なんていう方法がありますよ、というおおざっぱな提案でした。
……事故が減るといいなぁ。
& 変な所とか怪しいところとか危ない所があったら、突っ込んでもらえれば速攻で直します!!