
Bcryptパスワードハッシング:重要性と使い方
📷 Pixabay / PexelsBcryptパスワードハッシング:重要性と使い方
SHA-256ハッシュで保存されたパスワードは数分で解読される可能性があります。Bcryptは意図的に遅く設計されており、それこそが重要なのです。Bcryptの仕組み、コストファクターの選び方、Node.js・Python・PHPでの実装方法を学びましょう。
パスワード保存の問題
ログインシステムを構築していると想像してください。ユーザーがhunter2というパスワードでアカウントを作成します。来週ログインするときに正しいパスワードを入力したことを確認できるよう、データベースに何かを保存する必要があります。
最も単純な解決策はhunter2をそのまま保存することです。これは致命的です。データベース侵害、SQLインジェクション、設定ミスのS3バケットに残されたバックアップ — 攻撃者はすべてのユーザーの実際のパスワードを入手します。
次のステップはハッシュ化です。SHA-256("hunter2")は逆転できない固定長文字列を提供します。代わりにそれを保存します。良くなりましたよね?
より良い — でも十分ではありません。
問題はSHA-256が速くなるよう設計されているということです。最新のGPUは毎秒数十億のSHA-256ハッシュを計算できます。SHA-256でハッシュされたパスワードのデータベースと良いGPUを持つ攻撃者は、事前計算テーブル(レインボーテーブル)や辞書攻撃を使用して、数時間、時には数分で一般的なパスワードのかなりの部分を解読できます。
Bcryptはこれを解決するために特別に設計されました。意図的に遅く、その速度は設定可能です。
Bcrypt Hash Generatorを使って、セットアップ不要でbcryptハッシュを直接試してみましょう。
Bcryptとは何か
Bcryptは1999年にNiels ProvosとDavid MazièresがBlowfish暗号を基に設計したパスワードハッシング関数です。SHA-256やMD5(汎用暗号ハッシュ関数)とは異なり、bcryptはパスワード保存専用に最初から構築されました。
3つの特性がこれに適しています:
1. 意図的に遅い
Bcryptには設定可能なコストファクター(作業係数またはソルトラウンドとも呼ばれる)が含まれています。関数は内部的に2^コスト回の反復を実行します。コストファクターを1増やすと計算時間が2倍になります。つまり、ハードウェアに合わせて速度を調整でき、ハードウェアが速くなるにつれてコストファクターを上げて追い続けることができます。
SHA-256ハッシュにはマイクロ秒かかります。コスト12のbcryptハッシュには約200〜300ミリ秒かかります。その差は小さく聞こえますが、攻撃者の経済性を劇的に変えます。
2. 自動的にソルト処理を行う
ソルトはハッシュ前にパスワードに追加されるランダム値です。ソルティングにより、同じパスワードを持つ2人のユーザーが異なるハッシュを持つことが保証され、事前計算されたレインボーテーブル攻撃を防ぎます。
Bcryptは暗号学的にランダムな128ビットのソルトを自動的に生成し、出力ハッシュに埋め込みます。ソルトを自分で管理する必要はありません。
3. ハッシュが自己完結型
bcryptの出力には、アルゴリズムバージョン、コストファクター、ソルト、ハッシュがすべて1つの文字列に含まれています。つまり、ユーザーごとに1つの文字列だけを保存すれば良く、ソルトを別途取得せずにパスワードを検証できます。
典型的なbcryptハッシュは次のようになります:
$2b$12$LJ3m6gEwO/fSFqCVXWLwOeR/dYtTVkRDCwoGLBE0Fg6voFEOB5viy
分解すると:
$2b$ -- アルゴリズムバージョン(2bが現在の標準)
12$ -- コストファクター(2^12 = 4,096回のキースケジュール反復)
LJ3m6gEwO/fSFqCVXWLwOe -- 22文字のbase64エンコードされたソルト(128ビット)
R/dYtTVkRDCwoGLBE0Fg6voFEOB5viy -- 31文字のbase64エンコードされたハッシュ
ハッシングと暗号化:重要な区別
この区別は明確に述べるに値します。
ハッシングは一方向関数です。パスワードを入力するとハッシュが出力されます。キーも逆方向の操作もありません。パスワードがハッシュと一致するか確認する唯一の方法は、候補パスワードをハッシュして結果を比較することです。
暗号化は双方向関数です。キーでデータを暗号化し、同じ(または関連する)キーで元に戻すことができます。
パスワードは常に暗号化ではなくハッシュ化すべきです。パスワードを暗号化すると、システムのどこかに復号キーがあり、そのキーを入手した者はすべてのユーザーのパスワードを持つことになります。ハッシングでは、データベース侵害はパスワードではなくハッシュを暴露します — bcryptを使えば、それらのハッシュは解読が非常に困難です。
コストファクター:適切な遅さの選択
コストファクターはbcryptが行う計算量を直接制御します。各増分で作業量が2倍になります。
| コストファクター | 反復回数 | おおよその時間(典型的なサーバー) |
|---|---|---|
| 10 | 1,024 | ~65ms |
| 11 | 2,048 | ~130ms |
| 12 | 4,096 | ~250ms |
| 13 | 8,192 | ~500ms |
| 14 | 16,384 | ~1,000ms |
上記の時間はハードウェアによって大きく異なります。決定する前に実際の本番ハードウェアでベンチマークを実行してください。
OWASPの現在の推奨事項は最小コストファクター10で、ハッシュ時間100ms以上を目標とすることです。ほとんどの実務者は今日の合理的なデフォルトとして12を使用しています。
トレードオフ:
- 低すぎる(8以下):ハッシュが十分に速く、良いGPUを持つ攻撃者が漏洩したデータベースに対して素早く進められます。
- 高すぎる(15+):正当なログインリクエストが1秒以上かかり、ユーザーが気づき、攻撃者がログインエンドポイントを叩くとサービス拒否の可能性があります。
- 適正範囲(~12):ハッシュあたり~250msは攻撃者にとって有意義な妨害になるほど遅く、ユーザーが気づかないほど速いです。
ハードウェアが向上するにつれて、数年前にコスト12でハッシュされたパスワードは現在のハードウェアで多少解読しやすくなります。次回ログイン時(平文パスワードが検証のために利用可能な時)に、より高いコストファクターでアクティブユーザーのパスワードを定期的に再ハッシュすることが良い実践です。
Bcryptの実装:コード例
Node.js
bcryptパッケージ(および純粋JS代替のbcryptjs)が最も一般的な選択肢です。
import bcrypt from 'bcrypt';
const COST_FACTOR = 12;
// パスワードのハッシュ化
async function hashPassword(plaintext) {
const hash = await bcrypt.hash(plaintext, COST_FACTOR);
return hash;
// "$2b$12$..." -- この文字列をデータベースに保存
}
// ログイン時のパスワード検証
async function verifyPassword(plaintext, storedHash) {
const match = await bcrypt.compare(plaintext, storedHash);
return match; // true または false
}
// 使用例
const hash = await hashPassword('hunter2');
console.log(hash); // $2b$12$...
const valid = await verifyPassword('hunter2', hash);
console.log(valid); // true
const invalid = await verifyPassword('wrongpassword', hash);
console.log(invalid); // false
注意:bcrypt.compare()は内部的にタイミングセーフな比較を使用します。これにより、攻撃者が比較にかかる時間を測定して情報を推測するタイミング攻撃を防ぎます。
Express.js 登録・ログインルート
import express from 'express';
import bcrypt from 'bcrypt';
import { db } from './database.js';
const router = express.Router();
const COST_FACTOR = 12;
// 登録
router.post('/register', async (req, res) => {
const { email, password } = req.body;
// 基本的なバリデーション -- 本番環境ではより徹底的なチェックを追加
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: 'Invalid input' });
}
try {
const hash = await bcrypt.hash(password, COST_FACTOR);
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, hash]
);
res.status(201).json({ message: 'Account created' });
} catch (error) {
if (error.code === '23505') { // ユニーク制約違反
return res.status(409).json({ error: 'Email already registered' });
}
res.status(500).json({ error: 'Registration failed' });
}
});
// ログイン
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.query(
'SELECT id, password_hash FROM users WHERE email = $1',
[email]
);
// ユーザーが見つからなくても常にハッシュしてタイミングベースのユーザー列挙を防止
const hash = user.rows[0]?.password_hash ?? '$2b$12$invalidhashfortimingprotection';
const match = await bcrypt.compare(password, hash);
if (!user.rows[0] || !match) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// セッション / JWT を発行
res.json({ message: 'Logged in', userId: user.rows[0].id });
});
Python(bcryptパッケージ使用)
import bcrypt
COST_FACTOR = 12
def hash_password(plaintext: str) -> str:
"""パスワードをハッシュ化してbcryptハッシュ文字列を返します。"""
password_bytes = plaintext.encode('utf-8')
salt = bcrypt.gensalt(rounds=COST_FACTOR)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def verify_password(plaintext: str, stored_hash: str) -> bool:
"""平文パスワードを保存されたbcryptハッシュと検証します。"""
password_bytes = plaintext.encode('utf-8')
hash_bytes = stored_hash.encode('utf-8')
return bcrypt.checkpw(password_bytes, hash_bytes)
# 使用
hash_value = hash_password('hunter2')
print(hash_value) # $2b$12$...
print(verify_password('hunter2', hash_value)) # True
print(verify_password('wrongpassword', hash_value)) # False
PHP
<?php
// パスワードのハッシュ化
function hashPassword(string $plaintext): string {
return password_hash($plaintext, PASSWORD_BCRYPT, ['cost' => 12]);
}
// パスワードの検証
function verifyPassword(string $plaintext, string $storedHash): bool {
return password_verify($plaintext, $storedHash);
}
// ハッシュの再ハッシュが必要か確認(コストファクターを上げた場合など)
function needsRehash(string $storedHash): bool {
return password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => 12]);
}
// 使用
$hash = hashPassword('hunter2');
echo $hash; // $2y$12$...
var_dump(verifyPassword('hunter2', $hash)); // bool(true)
var_dump(verifyPassword('wrongpassword', $hash)); // bool(false)
// ログイン時:必要なら再ハッシュ
if (needsRehash($storedHash)) {
$newHash = hashPassword($plaintextPassword);
// データベースのハッシュを更新
}
?>
一般的なミスとその回避方法
平文または可逆エンコードでパスワードを保存
Base64はエンコードであり、ハッシングではありません。base64("hunter2")はaHVudGVyMg==です。これを見た攻撃者は1行でデコードできます。同様に、可逆暗号化方式ではキーを保存する必要があり、そのキーが単一障害点になります。
パスワードに高速ハッシュ関数を使用
SHA-256、SHA-512、MD5、SHA-1 — これらはすべて速くなるよう設計されています。速さはパスワードハッシングに間違った特性です。チェックサム、デジタル署名、データ整合性には完全に適切です。パスワードには間違いです。
72バイトの切り捨て問題
元のbcrypt仕様は入力の最初の72バイトのみを処理します。一部のライブラリは72バイトより長い入力を暗黙的に切り捨てます。標準的な軽減策は入力をまずSHA-256でハッシュし、そのハッシュをbcryptでハッシュすることです。
タイミングセーフな比較を使用しない
ライブラリのcompareやverify関数の代わりにハッシュを手動で比較する場合、定数時間比較関数を使用してください。
Bcryptハッシュのテスト
Bcrypt Hash Generatorは次のような用途に役立ちます:
- プロジェクトをセットアップせずに開発中のテストハッシュ生成
- 実装が有効なbcrypt出力を生成することの確認
- コードでのハッシュ検証の素早いテスト
- コストファクターを調整して出力プレフィックスがどう変わるかを確認
ツールはブラウザ内で完全に動作し、データをサーバーに送信しません。
結論
パスワードハッシングは「動作する」と「正しく動作する」の差が非常に重要な領域です。Bcryptは3つの重要なものを提供します:意図的な遅さ、自動ソルティング、自己完結型の出力形式。最新のオプションではありませんが、十分にテストされ、普遍的にサポートされており、正しいです。
コストファクター12を出発点として使用してください。実際のハードウェアでベンチマークを実行してください。サーバーが200msよりかなり速くハッシュできるなら上げてください。最初から始めるならArgon2idを検討してください。
関連ツールとリソース
- Bcrypt Hash Generator -- ブラウザでbcryptハッシュを生成・検証
- Password Generator -- 強力なランダムパスワードを生成
- Password Strength Checker -- パスワードの強度を評価
- Hash Generator -- MD5、SHA-1、SHA-256、SHA-512ハッシュを生成
- Hash Functions Explained -- SHA-256、MD5、暗号ハッシングの詳細解説