パスワードの安全な保存(ハッシュ化とソルト)とは?やってはいけない実装と正解

パスワードの安全な保存(ハッシュ化とソルト)とは?やってはいけない実装と正解

#セキュリティ#Web開発#脆弱性対策

パスワードは、データベースが漏洩した場合にも一括して復元されにくい形式で保存します。 新規実装では、利用者ごとのソルトを含むArgon2idなどのパスワード保存用アルゴリズムを使い、攻撃者が1秒間に試せる候補数を減らします。

なぜハッシュ化するのか

認証サービスは、入力されたパスワードが登録時と同じかを確認できればよく、元の文字列を読み戻す必要はありません。 そこで使うのがハッシュ化です。

ハッシュ関数は、入力から固定長の値を計算する一方向関数です。 ハッシュ値を復号して元のパスワードへ戻す操作はありません。 ただし、攻撃者は候補を一つずつハッシュ化し、保存値と一致するかを確認できます。 「元に戻せない」と「候補を推測できない」は別の性質です。

ログイン時は、入力されたパスワードを保存値に記録されたソルトとパラメータで処理し、得られた値を保存済みのハッシュと安全に比較します。 データベースを閲覧しただけでは元のパスワードは読めませんが、弱いパスワードはオフライン解析で判明する可能性があります。

暗号化は、鍵を使って元の値を復元できる可逆な処理です。 利用者本人をパスワードで認証する用途では、サービスが元のパスワードを取り出す必要はありません。 復号鍵の管理という一括侵害の経路を増やさないため、可逆暗号ではなくパスワード保存用のハッシュ方式を使います。

やってはいけない保存方法

危険性の大きいものから確認します。

  • 平文保存:データベースを取得した攻撃者や閲覧権限を持つ内部者に、パスワードをそのまま読まれる
  • 可逆な暗号化:復号鍵も侵害されると、保存したパスワードを一括して復元される
  • ソルトなしの高速ハッシュ:同じパスワードが同じ値になり、既存のレインボーテーブルや一度の計算結果を多数の利用者へ再利用される
  • ソルト付きのMD5やSHA-256:事前計算の使い回しは防げても、計算が高速なため、個々のハッシュへ大量の候補を試される
  • 全利用者で共通の固定ソルト:汎用の表は使いにくくなるものの、対象システム向けの計算結果をハッシュ間で再利用される
  • 独自の組み合わせ:ハッシュの反復やソルトの連結を自作し、保存形式、比較処理、パラメータ更新のいずれかを誤る

MD5とSHA-1には衝突耐性の問題もありますが、パスワード保存で直接問題になるのは高速に計算できることです。 SHA-256自体が安全な用途でも、パスワード保存へ直接使えることを意味しません。

正解となる保存方式

2026年時点では、次の順序で選びます。

  • Argon2id:新規実装の第一候補。メモリ使用量、反復回数、並列度を調整でき、GPUなどによる並列解析のコストを引き上げる
  • scrypt:Argon2idを利用できない場合の候補。CPUだけでなくメモリも消費させる
  • bcrypt:既存システムとの互換性が必要な場合の候補。入力が通常72バイトに制限されるため、長いパスワードを黙って切り捨てない
  • PBKDF2-HMAC-SHA-256:FIPS 140に準拠する実装が必要な環境での候補。十分な反復回数を設定する

方式を選んだ後も、次の条件が必要です。

  • 利用者ごとに、暗号学的に安全な乱数から一意のソルトを生成する
  • ライブラリが扱うソルトを使い、独自の連結処理を追加しない
  • 本番相当の環境で認証遅延とピーク時の資源消費を測定し、コストを決める
  • ハードウェアの更新に合わせてパラメータを見直し、古いハッシュをログイン成功時などに再計算する
  • パスワード全体を検証し、ライブラリの上限を超えた入力を黙って切り捨てない
  • 方式とパラメータをハッシュ文字列に含め、将来の移行を判定できるようにする

OWASP Password Storage Cheat Sheetの最低構成の一つは、Argon2idのメモリ19MiB、反復2回、並列度1です。 これはすべての環境に最適な固定値ではありません。 認証サーバの性能、同時実行数、サービス妨害への耐性を測定し、許容できる範囲で攻撃コストを引き上げます。

ソルトとペッパーの違い

ソルトは、パスワードごとに生成する一意の値です。 同じパスワードでも異なるハッシュを作り、事前計算した表や一度の計算結果を複数のハッシュへ使い回せないようにします。 ソルトは秘密ではなく、ハッシュと一緒に保存できます。

ペッパーは、複数のパスワードに共通して使う秘密値です。 パスワードデータベースとは別の秘密管理サービスやHSMへ保存し、データベースだけが漏洩した場合の追加防御にします。 ソースコードやデータベースへ埋め込んではいけません。

ペッパーは任意の追加対策であり、ソルトや適切なハッシュ方式の代わりにはなりません。 漏洩時に変更しても、元のパスワードなしでは新しいペッパーで既存ハッシュを作り直せないため、バージョン管理、段階的な移行、必要に応じたパスワード再設定まで事前に設計します。

Railsでの事例

Railsのhas_secure_passwordはbcryptを使い、ソルト、コスト係数、ハッシュをpassword_digestへ保存します。

# Gemfile
gem "bcrypt", "~> 3.1"

class User < ApplicationRecord
  has_secure_password
end
user = User.create(
  email: "a@example.com",
  password: "correct horse battery staple"
)

user.password_digest
# => "$2a$12$C6UzMDM.H6dfI/f/IKcEeO..."

user.authenticate("correct horse battery staple") # => user
user.authenticate("wrong")                         # => false

has_secure_passwordは、bcryptの制約に合わせてパスワードを72バイト以下に検証します。 英数字の72文字ではなく72バイトなので、日本語などのマルチバイト文字ではより短い文字数で上限に達します。 NIST SP 800-63Bが示す「少なくとも64文字を受け入れる」という基準と両立できない文字種もあるため、新規設計で長いUnicodeパスワードを受け入れる場合はArgon2idなどを優先します。

bcryptのコストは、固定値を記事からコピーして決めるものではありません。 現在の設定でハッシュの作成と検証にかかる時間を本番相当の環境で測り、ピーク時の認証数も踏まえて調整します。 コストを引き上げた後は、保存済みハッシュのコストを確認し、ログイン成功時に再ハッシュする移行処理も検討します。

TypeScript(Node.js)での事例

node-argon2は、ソルトを自動生成し、パラメータとともにPHC文字列形式へ保存します。

import * as argon2 from "argon2";

const digest = await argon2.hash("correct horse battery staple", {
  type: argon2.argon2id,
  memoryCost: 19456,
  timeCost: 2,
  parallelism: 1,
});

const ok = await argon2.verify(
  digest,
  "correct horse battery staple"
); // => true

memoryCostはKiB単位のメモリ量、timeCostは反復回数、parallelismは並列度です。 この例はOWASPが示す最低構成の一つに合わせています。 実運用では、同時ログインや攻撃による大量リクエストでもメモリが枯渇しないことを負荷試験で確認します。

適切な保存方式は、レインボーテーブルを使った事前計算を無効化し、オフライン推測を高コストにします。 それでも、短く予測しやすいパスワードは解析され得ます。 流出済みパスワードの拒否、十分な長さ、多要素認証、データベースと秘密管理基盤の保護を別の防御層として重ねます。

この記事をシェア