1-2. パスワードハッシュ - 平文保存は犯罪レベル
所要時間: 30-45分 ゴール: なぜパスワードは平文NGか、ハッシュ関数の性質、bcrypt/argon2id/scrypt の使い分け、salt と pepper の違いを説明できる。Goでbcryptを正しく使える コミット内容:
~/learn/security/day02_bcrypt/に動くサンプルコードを残す
前章とのつながり
このレッスンの位置づけ
前章 1-1 認証と認可 で 「認証は『誰か』を確かめるプロセス」 と整理した。その代表手段がパスワード認証。
でも「パスワードを保存する」だけで本章 1 回分のボリュームになる。ここを雑にすると、認証システム全体が無意味になる。
- 次章 TLS: 保存方法を固めても、ネットワーク上を平文で流していたら台無し → 通信路を守る
- Level 2 2-2 セッション管理: パスワード照合後、その「ログイン状態」をどう持つか
実害シーン: パスワード流出の代償
- Adobe (2013): 1.5億件流出。「暗号化」していたが共通鍵で復号可能だったため、ヒントから大量解読 → 「暗号化とハッシュ化を取り違えた」教科書事例
- LinkedIn (2012): 6.5億件。SHA-1 + salt なし → レインボーテーブルで翌日には数千万件解読
- 某日本SaaS (2023年代): パスワード平文ログ出力 → 退職した運用担当が顧客の他社サービスに不正ログイン
ユーザーは 同じパスワードを他社サービスで使い回す。あなたのサービスからの流出は 他社の被害にも直結する。これがクレデンシャル・スタッフィング攻撃の温床。
大前提: パスワード保存はバックエンドの責任の重い部分
平文でパスワードを保存しているサービスは DBが漏れた瞬間に全ユーザーのパスワードが流出する。しかも、ユーザーは多くのサービスでパスワードを使い回しているので、被害は他社サービスにも波及する。これが 「クレデンシャル・スタッフィング攻撃」 の温床。
実際に起きた有名インシデント:
- Adobe (2013): 1.5億件流出。パスワードを「暗号化」していたが共通鍵で、しかも復号化可能な方式を採用していたため大量解読された。暗号化とハッシュ化を取り違えた典型例
- LinkedIn (2012): 6.5億件流出。SHA-1(しかも salt なし)で保存していたため、レインボーテーブル攻撃で大半が解読された
- ヤフーBB (2004): 451万件流出。日本で個人情報保護意識が高まる契機となった事件
これらは「ハッシュ化 = 安全」と思い込んだ典型例。「ハッシュ化」と「正しくハッシュ化」は別物です。このレッスンで「正しい」の中身を理解する。
セッション①: なぜハッシュなのか、どう選ぶか(25-30分)
0. 録画&作業ディレクトリ
mkdir -p ~/log ~/learn/security/day02_bcrypt
cd ~/learn/security/day02_bcrypt
script ~/log/security_day02.log1. 平文保存の何が悪いか
平文(plaintext)保存の犯罪性
パスワードを
users.password = "hunter2"のままDBに入れること。何が悪いか:
- DBダンプが漏れた瞬間に全員のパスワードが裸
- DBA・運用担当が見ようと思えば見える(内部脅威)
- ログに混入する事故が頻発(クエリログ、エラーログ)
- バックアップ経由でも流出(バックアップは暗号化されてないことが多い)
- ユーザーは他サービスでパスワード使い回しているので、社外にも被害が拡大
日本では 個人情報保護法・電気通信事業法 の観点でも問題視される。GDPRなら罰金対象。
「内部の人間しか触らないから大丈夫」は通用しない。従業員1人の悪意で全社的事故になる。
2. 暗号化(encryption)とハッシュ化(hashing)の違い
暗号化 vs ハッシュ化 - ここを混同しない
観点 暗号化(encryption) ハッシュ化(hashing) 可逆性 可逆(鍵があれば復元できる) 不可逆(一方向) 目的 「あとで元に戻す」 「同じか比べる」 入力長 任意 任意 出力長 任意(暗号文として可変) 固定長 鍵 必要 不要(pepperは例外) パスワード保存 NG(鍵が漏れたら復元できる) OK(適切なアルゴリズム使えば) Adobeの事件はここを取り違えた: 暗号化していたから「漏れても安心」と思ったが、鍵も漏れて全解読された。
パスワードはユーザーから入力された値と「同じかどうか」を比べたいだけで、元の値を 取り出す必要がない。だからハッシュ(不可逆)で十分。
3. ハッシュ関数の性質
hash("hunter2") = "$2b$10$..."
hash("hunter2") = "$2b$10$..." ← 同じ入力なら同じ出力(一般のハッシュなら)
hash("hunter3") = "$2b$10$..." ← 1文字違えば全く違う出力(雪崩効果)
暗号学的ハッシュ関数の3つの性質
- 一方向性(preimage resistance): ハッシュ値から入力を逆算できない
- 第二原像困難性(second preimage resistance): 同じハッシュ値になる別の入力を見つけられない
- 衝突困難性(collision resistance): そもそも同じハッシュ値になる2つの入力を見つけられない
これらが破られると、ハッシュ関数として 死んだ ことになる。MD5・SHA-1 はすでに衝突が見つかっており、署名用途では使えない。
4. SHA-256 そのままじゃダメな理由
// NG: パスワード保存にSHA-256をそのまま使う
import "crypto/sha256"
hash := sha256.Sum256([]byte("hunter2"))SHA-256(や MD5、SHA-1)をパスワード保存に使ってはいけない
SHA-256 は暗号学的ハッシュとしては優秀だが、設計目標が「速い」こと。これが致命的。
GPU を使えば、現代のマシンは SHA-256 を 1秒間に数十億回 計算できる。攻撃者の手順:
- 流出した
usersテーブルを取得(メアド + ハッシュ)- よくあるパスワード辞書(「password」「123456」「qwerty」など数千万件)を全部 SHA-256
- ハッシュ値が一致した行 = パスワード判明
数億パスワードを 数分 で照合できてしまう。これが ブルートフォース攻撃 と 辞書攻撃。
しかも salt なしだと レインボーテーブル(事前計算した巨大なハッシュ→パスワードの逆引きDB)が公開されているので、計算する必要すらない。
5. パスワード保存専用アルゴリズム(slow hash)の思想
わざと遅くする = slow hash
パスワード保存専用の現代的アルゴリズムは、意図的に計算を遅くしてある。
- 普通の SHA-256: 1パスワード = マイクロ秒未満
- bcrypt(cost 10): 1パスワード = 約100ミリ秒
普通の利用(ログイン1回あたり)では100msは問題ない(むしろレスポンスタイムに含めて気にならない)。 でも攻撃者が10億通り試したい時:
- SHA-256: 数秒〜数分で終わる
- bcrypt: 約3年かかる
これが slow hash の本質。「ログイン処理の遅延」を許容する代わりに「攻撃コスト」を上げる、トレードオフ設計。
さらに メモリも食わせる(argon2、scrypt)ことで GPU/ASIC での並列計算を阻害する設計が現代の主流。
6. 主要アルゴリズム3兄弟
bcrypt (1999)
$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
│ │ └─ salt + ハッシュ値(base64)
│ └─ コスト係数(10 = 2^10回繰り返し)
└─ アルゴリズム識別子($2a$, $2b$ など)
bcrypt の特徴
- 最も実績がある(25年以上)。実装ライブラリが各言語に存在し、こなれている
- コスト係数で計算量を調整可能(cost 10 → cost 12 で4倍時間がかかる)
- 入力長制限 72バイト: これを超えた部分は無視される(地味な落とし穴)
- メモリ消費は小さい: GPU攻撃には弱め(argon2 より)
ユースケース: 困ったらコレ。新規プロジェクトでも安全な選択肢。Go なら
golang.org/x/crypto/bcrypt
argon2id (2015)
argon2id の特徴
- 2015年のパスワードハッシュコンペティション(PHC)優勝者
- OWASP の現代的推奨
- メモリも時間も両方コスト化できる(メモリハード)
- GPU/ASIC耐性が高い
- パラメータが3つ(time, memory, parallelism)あって調整が複雑
「argon2id」の id は interleaved(data-dependent と data-independent のハイブリッド)。サイドチャネル攻撃にも強い。
ユースケース: 新規プロジェクトで最高水準を狙うなら。ただしライブラリの成熟度は言語によりまちまち
scrypt (2009)
scrypt の特徴
- メモリハードなハッシュの先駆け
- bcrypt より GPU 耐性高、argon2 より歴史長め
- Litecoin など暗号通貨でも使われている
ユースケース: argon2 が使えない環境での代替
7. 結局どれを使えばいい?
2026年時点の推奨
- 新規プロジェクト: argon2id(できれば)or bcrypt(実績重視で)
- 既存システムのレガシー移行: まず bcrypt に。argon2id への再移行は二段階で
- 言語/ライブラリの推奨に従う: Go なら bcrypt、Rust なら argon2 が活発、など
どれを選んでも「SHA-256 そのままよりは桁違いに安全」。完璧を求めて何もしないより、bcrypt で運用開始するほうが100倍マシ。
セッション②: salt、pepper、Go での実装(25-30分)
8. salt(ソルト)の役割
password = "hunter2"
salt = ランダムな文字列(ユーザーごとに違う)
hash_to_store = bcrypt("hunter2" + salt, cost=10)
salt の本質
ユーザーごとにランダムな文字列をくっつけてからハッシュする。これで同じパスワードでも 別のハッシュ値 になる。
何のメリットがあるか
- レインボーテーブル無効化: 事前計算した辞書が役に立たなくなる(saltが違うのでハッシュ値も全部違う)
- 同じパスワード使い回しユーザーの保護: 「alice と bob が両方 password123 を使ってる」がDBから見えない
- DBダンプを得た攻撃者は、ユーザー1人ずつ攻撃しないといけない: 10億ユーザーいたら10億回別々に攻撃する必要がある
bcrypt / argon2 / scrypt は salt を自動生成して結果文字列に含める。プログラマが意識する必要はほぼない(ただし「ライブラリ任せにしている」自覚は持つ)。
NG: salt を全ユーザーで共通にする
// NG const sharedSalt = "mysecretsalt" hash := sha256.Sum256([]byte(password + sharedSalt))これは salt の意味を完全に台無しにしている。salt の本質は 「ユーザーごとに違う」 こと。
共通saltは「秘密のサフィックスを足した SHA-256」でしかなく、攻撃者がいずれそれを突き止めれば即座に辞書攻撃が成立する。
9. pepper との違い
pepper(ペッパー)とは
salt とは別に、全ユーザー共通だけど DB の外に置く 秘密値。
hash = bcrypt(password + salt + pepper)
- salt: ユーザーごとに違う、DBに保存
- pepper: 全ユーザー共通、アプリの環境変数や KMS(鍵管理サービス)に保存
メリット: DBだけ漏洩しても pepper が分からないと辞書攻撃が成立しない(pepper が攻撃者の総当たり対象になる)。
デメリット: pepper のキーローテーション運用が面倒。pepper を変えると全ハッシュが無効になる。
採用すべきか: 重要なシステムでは追加すべき。一方で「bcrypt + salt」で運用しているサービスがほとんどで、現実的には pepper まで導入しているチームは少数派。
salt と pepper を混同しない
- 「salt は秘密にすべき」← 誤解。salt は秘密ではない。同じハッシュ値の衝突を防ぐためのランダム値
- 「pepper があれば salt は不要」← 誤解。両者の役割は別。両方使うのが最強
10. Go で bcrypt を使うサンプル
mkdir -p ~/learn/security/day02_bcrypt
cd ~/learn/security/day02_bcrypt
go mod init bcrypt-sample
go get golang.org/x/crypto/bcrypt// main.go
package main
import (
"errors"
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
)
// HashPassword はパスワードを bcrypt でハッシュ化する。
// cost は計算量。10 が標準、サーバー性能に応じて 12 などへ。
func HashPassword(password string, cost int) (string, error) {
if len(password) > 72 {
// bcrypt の入力上限。事前にチェックしておく
return "", errors.New("password too long: bcrypt accepts at most 72 bytes")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword はハッシュとパスワードが一致するかを判定する。
func VerifyPassword(hash, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func main() {
password := "hunter2"
// 保存時: ハッシュ化
hash, err := HashPassword(password, bcrypt.DefaultCost) // DefaultCost = 10
if err != nil {
log.Fatal(err)
}
fmt.Println("hash:", hash)
// hash: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// ログイン時: 一致確認
if err := VerifyPassword(hash, "hunter2"); err == nil {
fmt.Println("OK: パスワード一致")
} else {
fmt.Println("NG: パスワード不一致")
}
// 間違ったパスワード
if err := VerifyPassword(hash, "wrongpass"); err != nil {
fmt.Println("OK(期待通り): 不一致を検知")
}
}このコードで起きていること
bcrypt.GenerateFromPassword: 内部で salt を自動生成してくっつけてハッシュ化。返り値の文字列に salt も含まれるbcrypt.CompareHashAndPassword: 保存されているハッシュ文字列から salt と cost を取り出して、入力パスワードを同じ条件でハッシュ化 → 一致比較- プログラマが salt を意識しなくていいのが bcrypt の良いところ
- 定数時間比較:
CompareHashAndPasswordはタイミング攻撃対策で内部的に定数時間比較を使っている。自前で==比較しないこと
cost の選び方
bcrypt.DefaultCost = 10(2^10回 = 1024回の繰り返し)- サーバーが余裕なら 12 へ上げると4倍時間(=4倍攻撃コスト)
- Webアプリのログインで100ms程度が目安。それより速ければ cost を上げる
- 上げすぎるとログインが体感遅くなるし、DoS耐性が下がる(攻撃者が大量ログイン試行でCPUを食わせる)
11. アンチパターン集
NG パターン1: SHA-256 そのまま
import "crypto/sha256" hash := sha256.Sum256([]byte(password)) // → GPU で秒で破られる。slow hash 使え
NG パターン2: MD5
import "crypto/md5" hash := md5.Sum([]byte(password)) // → MD5 は衝突が見つかっている。署名用途も含めて2020年代以降は使ってはいけない
NG パターン3: salt を全ユーザーで共通
const sharedSalt = "mycompany" hash := sha256.Sum256([]byte(password + sharedSalt)) // → salt の意味を完全に殺している
NG パターン4: 自前ハッシュ実装
// 「SHA-256 を1000回繰り返せばいいんでしょ?」 for i := 0; i < 1000; i++ { hash = sha256.Sum256(hash[:]) } // → DIY 暗号は事故の元。bcrypt / argon2 のような確立した実装を使う
NG パターン5: パスワードをログに出力
log.Printf("user login attempt: %s, password=%s", email, password) // → ログから漏洩。デバッグログでも絶対にやらない
12. JS/TS での実装も知っておく(FE出身者向け)
// Node.js with bcrypt
import bcrypt from "bcrypt";
const password = "hunter2";
// 保存時
const saltRounds = 10;
const hash = await bcrypt.hash(password, saltRounds);
console.log("hash:", hash);
// 検証時
const ok = await bcrypt.compare("hunter2", hash);
console.log("match:", ok);// 現代的推奨: argon2 ライブラリ
import argon2 from "argon2";
const hash = await argon2.hash("hunter2", { type: argon2.argon2id });
const ok = await argon2.verify(hash, "hunter2");Node.js での選択
- bcrypt パッケージ: 実績豊富。ネイティブ実装で速い
- bcryptjs: 純JS実装、遅いがネイティブビルド不要。Edge 環境用
- argon2: より最新、推奨度が上がっている
13. ログイン処理全体のベストプラクティス
ログインハンドラの正解形(疑似コード)
func LoginHandler(email, password string) error { // 1. レート制限(IPベース、アカウントベース) if rateLimiter.IsBlocked(email, clientIP) { return ErrRateLimited } // 2. ユーザー検索 user, err := db.FindUserByEmail(email) // 3. 「ユーザー存在しない」と「パスワード違う」を区別しない // どちらでも同じエラーメッセージを返す(ユーザー列挙攻撃対策) if err != nil { // ダミーハッシュとの比較で時間も揃える bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password)) return ErrInvalidCredentials } // 4. bcrypt 検証 if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)); err != nil { rateLimiter.RecordFailure(email, clientIP) return ErrInvalidCredentials } // 5. 成功: セッション発行、監査ログ session := CreateSession(user.ID) auditLog.Record(user.ID, "login_success", clientIP) return nil }
パスワードリセットの注意
リセット用トークンも ハッシュ化して保存。生のトークンはメールで送るだけで、DBには検証用ハッシュだけ持つ。
期限は短く(15分〜1時間)、ワンタイム(使ったら無効)、推測不能なランダム値で。
練習課題
# 1. 上の Go コードを動かす
cd ~/learn/security/day02_bcrypt
# main.go を上のコードで作成
go run main.go
# 2. cost を 10 から 12 に上げて時間を測る
time go run main.go
# 3. 同じパスワード "hunter2" を2回ハッシュ化して、別の値になることを確認
# (salt が毎回違うため)確認すべきこと
- cost 10 と cost 12 で体感速度が変わるか
- 同じ入力で
GenerateFromPasswordを呼ぶたびに別のハッシュが出ることCompareHashAndPasswordが両方のハッシュで「OK」を返すこと
締め: 振り返り(10分)
1. セッション録画を終了
exit2. 今日の発見
- bcrypt と SHA-256 の決定的な違い:
- salt と pepper の違いを後輩に説明するなら:
- 自分のサービスで cost をいくつにするか、根拠付きで:
- ヒヤッとした過去のコード(あれば):
チェックリスト
- 平文保存がなぜダメか、3つ理由を挙げられる
- 暗号化とハッシュ化の違いを言える
- SHA-256 単体ではダメな理由を言える
- bcrypt / argon2id / scrypt の特徴を比較できる
- salt と pepper の違いを言える
- Go で
bcrypt.GenerateFromPasswordを使ったサンプルを動かした - bcrypt の72バイト制限を知っている
- cost の選び方の指針が言える
詰まった時のチートシート
| やりたいこと | 答え |
|---|---|
| Go でハッシュ化 | bcrypt.GenerateFromPassword(pw, 10) |
| Go で検証 | bcrypt.CompareHashAndPassword(hash, pw) |
| 必要なパッケージ | golang.org/x/crypto/bcrypt |
| cost 推奨値 | 10〜12 |
| アルゴリズム第一推奨 | argon2id |
| アルゴリズム実績重視 | bcrypt |
| salt 自分で生成すべき? | bcrypt/argon2 が自動でやるので不要 |
| pepper はどこ? | アプリの env / KMS(DB外) |
| 平文保存 | 絶対NG |
| MD5 / SHA-1 | 使うな |
「実務OK」基準
- パスワード保存方法を新人エンジニアにレビューさせて指摘できる
- bcrypt の cost を「サーバー負荷とセキュリティのトレードオフ」として議論できる
- ユーザー列挙攻撃を意識した「同じエラーメッセージ」設計ができる
- タイミング攻撃を知っていて、定数時間比較を使う発想がある
メタ認知: 自分の理解度を診断する
30秒セルフテスト
- 「暗号化」と「ハッシュ化」の違いを 可逆性 の観点で説明せよ
- SHA-256 単体ではダメな理由は1単語で言うと?(ヒント: 速度)
- salt を 全ユーザー共通 にしたら何が起きる?
- bcrypt の cost を 10 → 12 にすると攻撃コストは何倍?
- ログインAPI で「ユーザー存在しない」と「パスワード違う」を別エラーで返してはいけない理由は?
詰まったら → セッション①の表(暗号化 vs ハッシュ化)と セクション 13 のログインハンドラ正解形を再読。
次のレッスン
1-3 HTTPSとTLS へ。ハッシュでパスワードを守っても、ネットワーク上を平文で流していたら意味がない。HTTPSの仕組み、TLSハンドシェイク、証明書チェーン、Let’s Encryptを次回扱う。
間隔反復ポイント
パスワード保存は コードレビューで毎月遭遇 する話題。1ヶ月後にこのページの「アンチパターン集」を再読すると、現場で
sha256.Sum256(password)を見た瞬間に違和感が起動するようになる。