記事一覧

パスワードはどう保存すれば安全なのか?ハッシュ、ソルト、スローハッシュ

パスワードを安全に保存する方法を、錠前とソルトの概念にたとえて整理しました。ハッシュとソルト、そしてbcryptやArgon2のような低速ハッシュアルゴリズムが欠かせない理由を、例とともに説明します。

目次

    図書館のロッカーを思い浮かべてみてください。管理人がロッカーの鍵の複製を全部壁にかけておけば便利ですが、泥棒が一度侵入すると、すべてのロッカーが開いてしまいます。パスワードを平文で保存するのがまさにこの状況です。では、鍵の代わりに「鍵の形の痕跡」だけを保管したらどうでしょうか?サーバーはパスワードそのものではなく、パスワードから作った指紋だけを覚えておきます。この指紋がハッシュです。この記事では、その指紋をどのように作り、なぜ「ゆっくり」作る必要があるのかを扱います。

    ハッシュ:データを復元できない固有の識別値。

    ハッシュ関数は、任意の入力値を固定長の文字列に変換する関数です。同じ入力に対しては常に一貫した結果値を返し、結果値から元のデータを逆算することは事実上不可能です。暗号化と違って復号鍵が存在しないという点が主な特徴です。

    ログインの流れは簡単です。会員登録時に入力値をハッシュ化して保存し、ログイン時には同じ方法でハッシュ化して、保存された値と照合する仕組みです。

    flowchart LR
        A[가입: 비밀번호 입력] --> B[해시 계산]
        B --> C[(DB에는 해시만 저장)]
        D[로그인: 비밀번호 입력] --> E[해시 계산]
        E --> F{저장된 해시와 같은가}
        C --> F
        F -->|같음| G[로그인 성공]
        F -->|다름| H[로그인 실패]

    この方式を適用すれば、データベースが流出しても、パスワードの原文がすぐに露出することはありません。ただし、これだけであれば話は終わったはずですが、ハッシュ方式だけでは二つの弱点が残ります。

    穴1. 同じパスワードは同じハッシュになる

    ハッシュアルゴリズムは同じ入力値に対して常に同じ結果値を生成しますが、この性質は攻撃者に悪用される恐れがあります。攻撃者は一般的なパスワードを多数あらかじめハッシュ化してデータベースを作り、流出した情報と照合するという手口を使います。これをレインボーテーブル攻撃と呼びます。

    さらに、同じパスワードを使っているユーザーは、データベース上でも同じハッシュ値が保存されます。そのため、ある一つのアカウントが侵害されると、同じハッシュ値を持つ他のアカウントまで同時に被害を受けるおそれがあります。

    ソルト。ユーザーごとに違う塩をふる

    解決策はソルト(Salt)を活用することです。ソルトはユーザーごとに個別に生成されるランダムな文字列で、パスワードと結合してハッシュ化する方式です。これは、同じ料理にユーザーごとに違う塩を加えて、結果としての味(ハッシュ値)をすべて違うものにするのと同じ原理です。

    flowchart LR
        P[비밀번호] --> M[섞기]
        S[사용자별 무작위 솔트] --> M
        M --> H[해시 계산]
        H --> R[솔트 + 해시를 함께 저장]

    ソルトは秘密ではありません。ハッシュの横にそのまま保存しても構いません。目的は隠すことではなく、あらかじめ計算した表を役に立たなくすることです。ソルトがユーザーごとに異なれば、攻撃者はアカウントごとに最初から計算し直さなければなりません。

    簡単な例で確認してみましょう。以下はPythonでソルトを適用したハッシュ化の実装例です。学習目的の例ですので、実際のサービス環境では、後で説明する専用ライブラリを使用してください。

    import hashlib, os
    
    def hash_with_salt(password: str):
        salt = os.urandom(16)  # 사용자마다 새로 만드는 16바이트 무작위 값
        digest = hashlib.sha256(salt + password.encode()).hexdigest()
        return salt.hex(), digest
    
    print(hash_with_salt("hello1234"))
    print(hash_with_salt("hello1234"))

    同じパスワードなのに、二回の出力がまったく違います。ソルトが毎回異なるからです。ここまでは確認できました。

    穴2. ハッシュが速すぎる

    SHA-256のような汎用ハッシュ関数は、高速な演算を目的として設計されています。これはファイルの完全性検証には効率的ですが、パスワード保護の観点からは弱点になります。データベースが流出すると、攻撃者は手元の計算資源を使ってオフラインで無制限の総当たり攻撃を行えます。特にGPUが毎秒数十億回に達する演算性能を持つことを考えると、単純だったりありふれたパスワードは素早く破られるおそれがあります。

    OWASPのガイドもこの点を明確にしています。SHA-256のような高速なハッシュアルゴリズムは、攻撃者が大量の総当たりを実行できるため、パスワードの保存には適さないと説明しています。防御側にできるのは、できるだけ多くの計算資源を消費するハッシュアルゴリズムを選び、オフライン攻撃を遅らせることだけです。

    ここで発想を転換してみましょう。一般のユーザーがログイン時にハッシュを一回計算するのにかかる0.1秒は、ほとんど体感できないレベルです。一方、攻撃者は数十億回の計算が必要なので、一回あたり0.1秒の遅延は致命的なコスト負担になります。

    意図的に演算速度を遅らせるように設計されたハッシュ関数

    スローハッシュ(Slow Hash)は、パスワードの計算コストを柔軟に調整できるように設計された専用のハッシュ関数で、代表的なアルゴリズムとしてbcryptとArgon2があります。現在のセキュリティ業界の標準的な推奨事項によると、OWASPはArgon2id、bcrypt、PBKDF2のような強力なスローハッシュアルゴリズムの利用を指針としています。

    アルゴリズムコストを上げる方法特徴
    bcryptワークファクター(work factor)長く検証されてきた方式。入力長の制限(72バイト)がある
    scryptCPU・メモリのコストメモリを多く使うよう設計されている
    Argon2idメモリ、反復回数、並列度2015年のパスワードハッシュ大会の優勝系統。新規サービスの第一候補
    PBKDF2反復回数FIPS-140認証が必要なときに選ぶ

    Argon2idが特別な理由はメモリを多く使わせるという点です。攻撃者がGPUの数千個のコアを回しても、コアごとにメモリが必要なので並列化の利点が小さくなります。パスワード解読用の機材の効率を下げる仕組みです。

    設定値はどの程度にすればよいか

    OWASPによるArgon2idの最小設定は、メモリ19 MiB、反復2回、並列度1です。同等の防御力を与える代替として、m=47104(46 MiB)、t=1、p=1のような組み合わせも示されています。bcryptを使うレガシー環境であれば、作業係数10以上と、パスワード長72バイトの制限を守るよう勧められています。

    上記の数値は基準点であり、OWASPも保護パラメータの設定時には実際のサーバー環境でのベンチマークの実施を推奨しています。ログイン速度の低下によってサーバーに過負荷がかかると、サービス拒否攻撃の原因になりかねないからです。

    小さな例で、実際に確かめてみよう

    実際のサービスでは、ハッシュ関数を自分で組み立てず、検証済みのライブラリを使ってください。Pythonのargon2-cffiの例です。

    from argon2 import PasswordHasher
    
    ph = PasswordHasher()  # 기본값이 안전하게 잡혀 있음
    
    stored = ph.hash("hello1234")
    print(stored)
    # $argon2id$v=19$m=...,t=...,p=...$솔트$해시
    
    ph.verify(stored, "hello1234")   # 맞으면 True
    # 틀리면 VerifyMismatchError 발생

    出力文字列ひとつに、アルゴリズム、バージョン、コスト設定、ソルト、ハッシュがすべて含まれています。ソルトを別途保存したり管理したりする必要はありません。ライブラリが生成し、検証のときに取り出して使います。

    コスト設定は定期的な引き上げが必要です。このとき、ユーザーが元のパスワードを入力する機会がログイン時しかないことを踏まえ、ログイン成功時に新しい設定で再ハッシュして保存する方式を採るのが一般的です。これにより、ユーザーに強制的なパスワード再設定を求めることなく運用できます。

    全体像をひと目で見る

    ここまでの流れを、登録とログインにまとめると次のようになります。

    flowchart TD
        A[비밀번호 입력] --> B[라이브러리가 무작위 솔트 생성]
        B --> C[느린 해시 계산: Argon2id 또는 bcrypt]
        C --> D[(솔트 + 비용 설정 + 해시 저장)]
        D --> E[로그인 시 같은 설정으로 다시 계산]
        E --> F{일치?}
        F -->|예| G[성공 + 필요하면 새 설정으로 재해시]
        F -->|아니오| H[실패]

    整理すると、役割は次のとおりです。ハッシュは元の値を隠し、ソルトは事前計算した表と同じハッシュの重複露出を防ぎ、遅いハッシュは大量の総当たりのコストを押し上げます。三つのうち一つでも欠ければ、穴が残ります。

    ひとつ付け加えます。どれほど厳重に保護対策をしても、「1234」のような単純なパスワードは、すぐに盗まれるおそれがあります。データの保存方法は、漏えい時の被害を抑える事後の補強策にすぎないので、ユーザーに複雑で固有のパスワードを設定してもらうよう促すことも、サービス側の重要な役割です。データ保護のための保存方法は最後の防衛線であり、先に行うべき予防策については、後続の記事で詳しく扱います。

    暗号化して保存してはいけないのですか?

    暗号化は鍵があれば元の値に戻せます。サーバーや鍵が盗まれると、すべてのパスワードが一度に露わになります。サーバーは元の値を知る必要がないので、元に戻せないハッシュが正解です。

    すでにSHA-256やMD5で保存しているなら?

    新しい方式へ移行するのがよいでしょう。元の値がわからないので一括変換は難しく、ユーザーがログインに成功したときに新しいアルゴリズムで再ハッシュして置き換える方法が現実的です。移行期間中は、ハッシュの前にアルゴリズムの識別子を付けて区別してください。

    ソルトは秘密にすべきですか?

    いいえ。ソルトはハッシュと一緒に保存しても問題ありません。攻撃者がソルトを知っていても、ユーザーごとに異なるため、事前計算した表を使い回せないことが重要なポイントです。別途もう一つ秘密の値を用意する場合はペッパーと呼ばれ、あくまで補助的な手段です。

    コメント 0

    1. コメントを読み込んでいます。