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 存储在哪里?我们如何管理加密密钥?当密钥需要轮换时会发生什么?

在下一篇文章中:密钥管理——安全地生成、存储、轮换和销毁密码学密钥。