Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

密碼儲存:為什麼你永遠不應該加密密碼

| , 12 minutes reading.

1. 為什麼要關心這個問題?

你正在建構一個帶有使用者帳戶的應用程式。使用者輸入密碼,你需要在資料庫中儲存某些東西以便之後驗證他們。

如果你的資料庫被洩露(假設它會被洩露),你的使用者密碼會怎樣?

糟糕的密碼儲存已經導致數十億憑證洩露。讓我們理解什麼是「好的」儲存方式。

2. 為什麼不使用加密?

加密密碼的問題

如果你加密密碼:

儲存: AES-GCM(key, "password123") → 密文
驗證: AES-GCM-decrypt(key, 密文) → "password123"

問題:
1. 你有解密金鑰
2. 任何獲得金鑰的人可以獲取所有密碼
3. 你可以看到使用者的實際密碼
4. 金鑰管理成為關鍵弱點

這是錯誤的。你永遠不應該能夠恢復密碼。

我們實際需要什麼

密碼儲存的需求:
1. 驗證:可以檢查輸入的密碼是否正確
2. 單向:無法從儲存值恢復原始密碼
3. 唯一:相同密碼 → 不同的儲存值(每個使用者)
4. 慢速:計算成本高(抵抗暴力破解)
5. 面向未來:可以隨時間增加難度

3. 為什麼不使用普通雜湊?

簡單的方法(非常危險)

import hashlib

# 錯誤:普通雜湊
def store_password(password):
    return hashlib.sha256(password.encode()).hexdigest()

def verify_password(password, stored):
    return hashlib.sha256(password.encode()).hexdigest() == stored

# 問題:相同密碼 = 相同雜湊
store_password("password123")  # 總是相同的輸出!

攻擊 1:彩虹表

預先計算常見密碼的雜湊:

彩虹表:
password123  → ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
123456       → 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
qwerty       → 65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5
...數百萬條更多...

攻擊:在表中查找雜湊 → 立即恢復密碼

攻擊 2:相同雜湊 = 相同密碼

資料庫洩露:
user1: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
user2: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
user3: 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92

攻擊者看到:user1 和 user2 有相同的密碼!
破解一個,獲得兩個。

4. 加鹽:每個使用者唯一

添加鹽值

import hashlib
import os

def store_password(password):
    salt = os.urandom(16)  # 每個使用者隨機
    hash_input = salt + password.encode()
    password_hash = hashlib.sha256(hash_input).hexdigest()
    return salt.hex() + ":" + password_hash

def verify_password(password, stored):
    salt_hex, stored_hash = stored.split(":")
    salt = bytes.fromhex(salt_hex)
    hash_input = salt + password.encode()
    computed_hash = hashlib.sha256(hash_input).hexdigest()
    return computed_hash == stored_hash

# 現在相同密碼 → 不同雜湊
print(store_password("password123"))  # 每次不同!
print(store_password("password123"))  # 又不同!

鹽值解決了一些問題

有鹽值:
✓ 彩虹表無效(需要每個鹽值一個表)
✓ 相同密碼 → 不同儲存值
✓ 無法識別使用相同密碼的使用者

仍然有問題:
✗ SHA-256 太快了!
✗ GPU 可以每秒計算數十億次雜湊
✗ 暴力破解仍然可行

5. 速度問題

現代 GPU 攻擊速度

RTX 4090 上的 Hashcat(大約):

SHA-256:           22,000,000,000 H/s(220億/秒)
MD5:               164,000,000,000 H/s

對於 8 字元小寫密碼(26^8 = 2080億):
SHA-256: 2080億 / 220億 = ~10 秒
MD5: 2080億 / 1640億 = ~1.3 秒

對於 8 字元混合大小寫 + 數字(62^8 = 218兆):
SHA-256: 218兆 / 220億 = ~2.7 小時
MD5: 218兆 / 1640億 = ~22 分鐘

這就是為什麼我們需要慢速雜湊函數!

解決方案:工作因子

密碼雜湊演算法包含故意的慢速:

bcrypt:    成本因子(2^cost 次迭代)
scrypt:    CPU 成本、記憶體成本、平行化
Argon2:    時間成本、記憶體成本、平行度

目標:使每次雜湊嘗試需要 ~100ms-1s
攻擊者做 10 億次嘗試現在需要 3+ 年

6. bcrypt

bcrypt 如何工作

bcrypt 設計:
1. 基於 Blowfish 密碼
2. 昂貴的金鑰設置階段
3. 成本因子控制迭代次數(2^cost)
4. 內建鹽值(22 字元)
5. 輸出:60 字元

格式: $2b$cost$salt(22)hash(31)
範例: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/BoIYq6h.Cg0f3Fy/q
      ─┬──┬───────────────────────┬─────────────────────────────────
       │  │         鹽值                      雜湊
       │  └── 成本因子(12 = 2^12 = 4096 次迭代)
       └── 演算法版本(2b = 現代 bcrypt)

Python 中的 bcrypt

import bcrypt

def hash_password(password: str) -> str:
    """雜湊密碼用於儲存"""
    # 產生鹽值和雜湊(成本因子 12 是好的預設值)
    password_bytes = password.encode('utf-8')
    salt = bcrypt.gensalt(rounds=12)  # 2^12 = 4096 次迭代
    hashed = bcrypt.hashpw(password_bytes, salt)
    return hashed.decode('utf-8')

def verify_password(password: str, hashed: str) -> bool:
    """驗證密碼與儲存的雜湊"""
    password_bytes = password.encode('utf-8')
    hashed_bytes = hashed.encode('utf-8')
    return bcrypt.checkpw(password_bytes, hashed_bytes)

# 使用
stored = hash_password("my_secure_password")
print(f"儲存: {stored}")
# $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/BoIYq6h.Cg0f3Fy/q

# 驗證
print(verify_password("my_secure_password", stored))  # True
print(verify_password("wrong_password", stored))       # False

bcrypt 的限制

bcrypt 問題:
- 72 位元組密碼限制(截斷更長的密碼)
- 固定記憶體使用(不是記憶體困難的)
- 可以用專用硬體加速

長密碼的解決方法:
def hash_long_password(password: str) -> str:
    # 預雜湊以處理任意長度
    import hashlib
    pre_hash = hashlib.sha256(password.encode()).digest()
    import base64
    shortened = base64.b64encode(pre_hash)[:72]
    return hash_password(shortened.decode())

7. Argon2(推薦)

為什麼選擇 Argon2?

Argon2 贏得了密碼雜湊競賽(2015):

三個變體:
- Argon2d: 最大 GPU 抵抗力,易受側通道攻擊
- Argon2i: 側通道抵抗,用於密碼雜湊
- Argon2id: 混合(推薦),兩者的優點

特點:
- 記憶體困難(可配置記憶體使用)
- 時間可配置(迭代次數)
- 平行度可配置(CPU 執行緒)
- 沒有密碼長度限制

Python 中的 Argon2

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

# 使用推薦參數建立雜湊器
ph = PasswordHasher(
    time_cost=3,        # 迭代次數
    memory_cost=65536,  # 64 MB 記憶體
    parallelism=4,      # 4 個平行執行緒
    hash_len=32,        # 輸出長度
    salt_len=16         # 鹽值長度
)

def hash_password(password: str) -> str:
    """使用 Argon2id 雜湊密碼"""
    return ph.hash(password)

def verify_password(password: str, hashed: str) -> bool:
    """驗證密碼與儲存的雜湊"""
    try:
        ph.verify(hashed, password)
        return True
    except VerifyMismatchError:
        return False

def needs_rehash(hashed: str) -> bool:
    """檢查雜湊是否需要用新參數更新"""
    return ph.check_needs_rehash(hashed)

# 使用
stored = hash_password("my_secure_password")
print(f"儲存: {stored}")
# $argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0$hash...

print(verify_password("my_secure_password", stored))  # True

# 隨時間升級參數
if verify_password("my_secure_password", stored) and needs_rehash(stored):
    new_hash = hash_password("my_secure_password")
    # 在資料庫中儲存 new_hash

選擇 Argon2 參數

OWASP 建議(2024):

最低:
- Argon2id
- m=19456(19 MB),t=2,p=1

推薦:
- Argon2id
- m=65536(64 MB),t=3,p=4

高安全性:
- Argon2id
- m=262144(256 MB),t=4,p=8

調優方法:
1. 設定記憶體為伺服器可以承受的最大值
2. 增加 time_cost 直到雜湊需要 ~0.5-1 秒
3. 設定平行度為可用核心數

8. scrypt

何時使用 scrypt

scrypt 優勢:
- 記憶體困難(像 Argon2)
- 自 2009 年以來經過良好研究
- 用於一些加密貨幣

何時使用:
- 當 Argon2 不可用時
- 用於金鑰衍生(類似 HKDF 的用例)
- 與現有系統相容

Python 中的 scrypt

from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
import os

def hash_password_scrypt(password: str) -> tuple[bytes, bytes]:
    """用 scrypt 雜湊密碼"""
    salt = os.urandom(16)

    kdf = Scrypt(
        salt=salt,
        length=32,
        n=2**17,  # CPU/記憶體成本(必須是 2 的冪)
        r=8,      # 區塊大小
        p=1       # 平行化
    )

    key = kdf.derive(password.encode())
    return salt, key

def verify_password_scrypt(password: str, salt: bytes, stored_key: bytes) -> bool:
    """用 scrypt 驗證密碼"""
    kdf = Scrypt(
        salt=salt,
        length=32,
        n=2**17,
        r=8,
        p=1
    )

    try:
        kdf.verify(password.encode(), stored_key)
        return True
    except Exception:
        return False

# 使用
salt, key = hash_password_scrypt("my_password")
print(verify_password_scrypt("my_password", salt, key))  # True

9. 比較

┌─────────────┬──────────┬────────────┬─────────────┬────────────────┐
│ 演算法      │ 記憶體   │ 平行      │ 推薦        │ 備註           │
│             │ 困難     │ 抵抗      │             │                │
├─────────────┼──────────┼────────────┼─────────────┼────────────────┤
│ Argon2id    │ ✓        │ ✓          │ ✓✓✓         │ 最佳選擇       │
│ scrypt      │ ✓        │ 部分      │ ✓✓          │ 好的備選       │
│ bcrypt      │ ✗        │ 部分      │ ✓           │ 仍然可以       │
│ PBKDF2      │ ✗        │ ✗          │ 僅遺留      │ 使用 60萬迭代  │
│ SHA-256     │ ✗        │ ✗          │ ✗           │ 永不使用       │
│ MD5         │ ✗        │ ✗          │ ✗✗✗         │ 永不使用       │
└─────────────┴──────────┴────────────┴─────────────┴────────────────┘

記憶體困難:需要大量 RAM,更難在 GPU 上平行化
平行抵抗:難以用多核/GPU 加速

10. 完整實現

"""
生產就緒的密碼雜湊模組
"""
from argon2 import PasswordHasher, Type
from argon2.exceptions import VerifyMismatchError, InvalidHashError
import secrets
import hmac

class PasswordManager:
    """使用 Argon2id 的安全密碼雜湊"""

    def __init__(
        self,
        time_cost: int = 3,
        memory_cost: int = 65536,  # 64 MB
        parallelism: int = 4,
        pepper: bytes = None  # 伺服器端金鑰
    ):
        self.hasher = PasswordHasher(
            time_cost=time_cost,
            memory_cost=memory_cost,
            parallelism=parallelism,
            hash_len=32,
            salt_len=16,
            type=Type.ID  # Argon2id
        )
        self.pepper = pepper

    def _apply_pepper(self, password: str) -> str:
        """在雜湊前添加 pepper 到密碼"""
        if self.pepper:
            # HMAC 防止長度擴展攻擊
            peppered = hmac.new(
                self.pepper,
                password.encode(),
                'sha256'
            ).hexdigest()
            return peppered
        return password

    def hash(self, password: str) -> str:
        """雜湊密碼用於儲存"""
        if not password:
            raise ValueError("密碼不能為空")

        peppered = self._apply_pepper(password)
        return self.hasher.hash(peppered)

    def verify(self, password: str, hash: str) -> bool:
        """驗證密碼與雜湊"""
        if not password or not hash:
            return False

        peppered = self._apply_pepper(password)

        try:
            self.hasher.verify(hash, peppered)
            return True
        except (VerifyMismatchError, InvalidHashError):
            return False

    def needs_rehash(self, hash: str) -> bool:
        """檢查雜湊是否需要用新參數更新"""
        try:
            return self.hasher.check_needs_rehash(hash)
        except InvalidHashError:
            return True

    def verify_and_rehash(self, password: str, hash: str) -> tuple[bool, str | None]:
        """驗證密碼並在參數更改時返回新雜湊"""
        if not self.verify(password, hash):
            return False, None

        if self.needs_rehash(hash):
            return True, self.hash(password)

        return True, None


# 使用範例
def example_usage():
    # 使用可選的 pepper 初始化(儲存在環境變數中,不是程式碼!)
    pepper = secrets.token_bytes(32)  # 生產中:從環境獲取
    pm = PasswordManager(pepper=pepper)

    # 註冊
    password = "user_password_123"
    hashed = pm.hash(password)
    print(f"儲存的雜湊: {hashed[:50]}...")

    # 登入
    is_valid = pm.verify(password, hashed)
    print(f"密碼有效: {is_valid}")

    # 檢查是否需要重新雜湊(升級參數後)
    is_valid, new_hash = pm.verify_and_rehash(password, hashed)
    if new_hash:
        print("雜湊已升級,在資料庫中儲存 new_hash")


if __name__ == "__main__":
    example_usage()

11. 常見錯誤

錯誤 1:不安全地比較雜湊

# 錯誤:時序攻擊漏洞
def verify_bad(password, stored_hash):
    computed = hash_password(password)
    return computed == stored_hash  # 字串比較洩露時間資訊

# 正確:使用常量時間比較
import hmac
def verify_good(password, stored_hash):
    computed = hash_password(password)
    return hmac.compare_digest(computed, stored_hash)

# 最佳:使用函式庫的內建驗證函數
# (bcrypt.checkpw,argon2.verify 已經處理這個問題)

錯誤 2:硬編碼參數

# 錯誤:程式碼中的參數
def hash_password(pwd):
    return argon2.hash(pwd, time_cost=2, memory_cost=32768)

# 正確:可配置,允許升級
class PasswordConfig:
    TIME_COST = int(os.environ.get('ARGON2_TIME_COST', 3))
    MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_KB', 65536))
    PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4))

錯誤 3:不處理升級

# 始終在成功登入後檢查是否需要重新雜湊
def login(username, password):
    user = get_user(username)

    if not verify_password(password, user.password_hash):
        return False

    # 如果使用舊參數則升級雜湊
    if needs_rehash(user.password_hash):
        user.password_hash = hash_password(password)
        save_user(user)

    return True

12. 本章小結

三點要記住:

  1. 永遠不要加密密碼,永遠不要使用普通雜湊。 加密是可逆的,普通雜湊太快了。使用專門建構的密碼雜湊演算法。

  2. Argon2id 是最佳選擇。 它是記憶體困難的、可配置的,並贏得了密碼雜湊競賽。如果 Argon2 不可用則使用 bcrypt。

  3. 調整參數使雜湊時間約為 0.5-1 秒。 這使暴力破解不切實際,同時保持登入可接受。隨著硬體改進,隨時間增加參數。

13. 下一步

我們可以安全地雜湊密碼。但 pepper 儲存在哪裡?我們如何管理加密金鑰?當金鑰需要輪換時會發生什麼?

在下一篇文章中:金鑰管理——安全地產生、儲存、輪換和銷毀密碼學金鑰。