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.log

1. 平文保存の何が悪いか

平文(plaintext)保存の犯罪性

パスワードを users.password = "hunter2" のままDBに入れること。

何が悪いか:

  1. DBダンプが漏れた瞬間に全員のパスワードが裸
  2. DBA・運用担当が見ようと思えば見える(内部脅威)
  3. ログに混入する事故が頻発(クエリログ、エラーログ)
  4. バックアップ経由でも流出(バックアップは暗号化されてないことが多い)
  5. ユーザーは他サービスでパスワード使い回しているので、社外にも被害が拡大

日本では 個人情報保護法・電気通信事業法 の観点でも問題視される。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つの性質

  1. 一方向性(preimage resistance): ハッシュ値から入力を逆算できない
  2. 第二原像困難性(second preimage resistance): 同じハッシュ値になる別の入力を見つけられない
  3. 衝突困難性(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秒間に数十億回 計算できる。攻撃者の手順:

  1. 流出した users テーブルを取得(メアド + ハッシュ)
  2. よくあるパスワード辞書(「password」「123456」「qwerty」など数千万件)を全部 SHA-256
  3. ハッシュ値が一致した行 = パスワード判明

数億パスワードを 数分 で照合できてしまう。これが ブルートフォース攻撃辞書攻撃

しかも 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 の本質

ユーザーごとにランダムな文字列をくっつけてからハッシュする。これで同じパスワードでも 別のハッシュ値 になる。

何のメリットがあるか

  1. レインボーテーブル無効化: 事前計算した辞書が役に立たなくなる(saltが違うのでハッシュ値も全部違う)
  2. 同じパスワード使い回しユーザーの保護: 「alice と bob が両方 password123 を使ってる」がDBから見えない
  3. 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. セッション録画を終了

exit

2. 今日の発見

- 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秒セルフテスト

  1. 「暗号化」と「ハッシュ化」の違いを 可逆性 の観点で説明せよ
  2. SHA-256 単体ではダメな理由は1単語で言うと?(ヒント: 速度)
  3. salt を 全ユーザー共通 にしたら何が起きる?
  4. bcrypt の cost を 10 → 12 にすると攻撃コストは何倍?
  5. ログインAPI で「ユーザー存在しない」と「パスワード違う」を別エラーで返してはいけない理由は?

詰まったら → セッション①の表(暗号化 vs ハッシュ化)と セクション 13 のログインハンドラ正解形を再読。

次のレッスン

1-3 HTTPSとTLS へ。ハッシュでパスワードを守っても、ネットワーク上を平文で流していたら意味がない。HTTPSの仕組み、TLSハンドシェイク、証明書チェーン、Let’s Encryptを次回扱う。

間隔反復ポイント

パスワード保存は コードレビューで毎月遭遇 する話題。1ヶ月後にこのページの「アンチパターン集」を再読すると、現場で sha256.Sum256(password) を見た瞬間に違和感が起動するようになる。