Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

哈希函数到底是不是加密

| , 5 minutes reading.

1. 为什么要关心这个问题?

一个开发者这样存储用户密码:

hashed = sha256(password)
database.store(hashed)

“这很安全,“他们说。“我用的是 SHA-256,一个强大的哈希函数。”

然后他们的数据库泄露了。几小时内,攻击者恢复了 80% 的密码。

哪里出错了?

这个开发者把”哈希”和”安全密码存储”混淆了。这两者不是一回事。SHA-256 是一个哈希函数,不是密码存储解决方案。直接用它来存密码就像用锤子当螺丝刀——用错了工具。

2. 定义

哈希函数接受任意大小的输入,产生固定大小的输出(“哈希值”或”摘要”)。它被设计成单向的:你可以从输入计算哈希,但无法从哈希计算出输入。

密码学哈希函数的关键特性:

  • 确定性: 相同输入总是产生相同输出
  • 固定输出大小: SHA-256 总是输出 256 位,无论输入多大
  • 单向性: 计算上不可逆
  • 抗碰撞性: 难以找到两个产生相同哈希的不同输入
  • 雪崩效应: 输入的微小变化导致输出剧烈变化

3. 根本区别

加密:设计上双向

明文 ──[用密钥加密]──► 密文 ──[用密钥解密]──► 明文

加密是可逆的。有了密钥,你总能恢复原始数据。

哈希:设计上单向

输入 ──[哈希函数]──► 哈希值

    (无法返回)

哈希是不可逆的。没有密钥。没有解密。原始数据在数学上被销毁了——只留下一个指纹。

为什么会混淆?

两者都从可读的输入产生”乱码输出”。但目的完全不同:

特性加密哈希
目的临时隐藏数据永久创建指纹
可逆是(有密钥)
需要密钥
输出大小随输入变化固定
使用场景保护传输/存储中的数据验证完整性,存储密码

4. 哈希函数如何工作

高层流程

┌─────────────────────────────────────────────────────────────┐
│ 1. 填充                                                     │
│    - 添加比特使输入成为块大小的整数倍                       │
├─────────────────────────────────────────────────────────────┤
│ 2. 分块处理                                                 │
│    - 分割成固定大小的块                                     │
│    - 每个块通过压缩函数处理                                 │
│    - 每个块的输出馈入下一个块                               │
├─────────────────────────────────────────────────────────────┤
│ 3. 最终化                                                   │
│    - 输出最终内部状态作为哈希值                             │
└─────────────────────────────────────────────────────────────┘

雪崩效应

这是哈希对完整性检查有用的原因:

import hashlib

text1 = "Hello, World!"
text2 = "Hello, World."  # 只是把 ! 改成了 .

print(hashlib.sha256(text1.encode()).hexdigest())
# dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f

print(hashlib.sha256(text2.encode()).hexdigest())
# f8c3bf62a9aa3e6fc1619c250e48abe7519373d3edf41be62eb5dc45199af2ef

一个字符的变化 → 完全不同的哈希。这使得”猜测”原始输入变得不可能。

5. 为什么你无法”解密”哈希

信息丢失

哈希函数将任意长度的输入压缩成固定长度的输出。信息在数学上丢失了。

"Hello"(5 字节)        → 256 位哈希
"战争与和平"(3MB)      → 256 位哈希
所有可能的文件           → 256 位哈希

无限的输入映射到有限的输出。多个输入会产生相同的哈希(碰撞)。你无法逆转这个过程,因为你不知道使用了无限可能输入中的哪一个。

没有密钥,就没有解密

加密在没有密钥时是安全的,因为找到密钥在计算上不可行。

哈希没有密钥。没有什么可找的。“逆转”需要反转一个被设计成不可逆的数学函数。

“破解”哈希意味着什么

当我们说哈希被”破解”时,我们指的是:

  • 碰撞攻击: 找到了两个具有相同哈希的不同输入
  • 原像攻击: 给定一个哈希,找到某个产生它的输入(不一定是原始的)

两者都不意味着”解密”。即使是被破解的哈希函数也不会变得可逆。

6. MD5:为什么死而不僵

MD5 是 1991 年设计的。它从 2004 年就被”破解”了。但你仍然到处能看到它。

为什么 MD5 是破碎的

  1. 碰撞攻击是实用的: 你可以创建两个具有相同 MD5 哈希的不同文件
  2. 选定前缀攻击有效: 给定任意两个前缀,你可以向每个追加数据,使结果具有相同的哈希
  3. 它太快了: 现代 GPU 每秒 95 亿个 MD5 哈希

为什么 MD5 死而不僵

# 在生产系统中仍然能看到:
file_checksum = hashlib.md5(file_content).hexdigest()  # "只是用于完整性"
cache_key = hashlib.md5(query).hexdigest()  # "只是用于缓存键"

人们辩称:“我不是用它来做安全,只是校验和。”

问题在于: 需求会变。今天的”只是校验和”会变成明天的安全控制。而且 MD5 太快了,即使非安全用途也会启用攻击。

MD5 实际上没问题的场景

  • 比较你完全控制的文件
  • 封闭系统中的非安全校验和
  • 遗留系统兼容性(完全了解风险的情况下)

MD5 不行的场景

  • 任何安全敏感的应用
  • 面向用户的文件验证
  • 密码哈希(绝对不行!)
  • 数字签名
  • 证书验证

7. 密码存储:正确的哈希方式

以下是为什么 sha256(password) 会失败:

问题 1:速度

SHA-256 被设计成很快。非常快。

SHA-256:     ~85 亿次哈希/秒 (GPU)
bcrypt:      ~71,000 次哈希/秒 (同样的 GPU)
Argon2:      ~1,000 次哈希/秒 (同样的 GPU,经过调优)

快速哈希意味着快速破解。一个 8 字符的密码大约有 6 千万亿种可能。以每秒 85 亿次的速度,全部尝试需要 8 天。

问题 2:没有盐

没有盐,相同的密码有相同的哈希。

数据库泄露:
user1: 5e884898da28047d9...  ← "password"
user2: 5e884898da28047d9...  ← 也是 "password"
user3: 5e884898da28047d9...  ← 也是 "password"

攻击者预先计算常见密码的哈希(彩虹表)。一次查找,数千个账户被攻破。

问题 3:彩虹表

预计算的表,将常见密码映射到它们的哈希。仅使用 SHA-256,一个 10GB 的彩虹表可以即时破解大多数弱密码。

解决方案:密码哈希函数

import bcrypt
import argon2

# bcrypt:久经考验,广泛支持
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))

# argon2:密码哈希竞赛的现代赢家
ph = argon2.PasswordHasher(
    time_cost=2,
    memory_cost=102400,  # 100 MB
    parallelism=8
)
hashed = ph.hash(password)

这些函数是:

  • 故意慢: 可配置的工作因子
  • 内存密集: (Argon2)需要大量 RAM,击败 GPU 攻击
  • 自动加盐: 即使密码相同,每个哈希也是唯一的

正确的密码存储流程

注册:
password → [盐 + 慢哈希] → stored_hash

验证:
input_password + stored_salt → [相同的慢哈希] → 与 stored_hash 比较

8. 哈希函数选择指南

使用场景推荐避免
密码存储Argon2id, bcrypt, scryptSHA-*, MD5
文件完整性SHA-256, SHA-3, BLAKE3MD5, SHA-1
数字签名SHA-256, SHA-3MD5, SHA-1
HMACSHA-256, SHA-3MD5
非安全校验和CRC32, xxHash(都可以)
内容寻址存储SHA-256, BLAKE3MD5

9. 代码示例:正确的密码处理

import argon2
from argon2 import PasswordHasher, exceptions

# 配置:根据你的服务器能力调整
ph = PasswordHasher(
    time_cost=2,      # 迭代次数
    memory_cost=65536, # 64 MB 内存使用
    parallelism=4,     # 并行线程数
    hash_len=32,       # 输出哈希长度
    salt_len=16        # 盐长度
)

def hash_password(password: str) -> str:
    """哈希密码用于存储。"""
    return ph.hash(password)

def verify_password(stored_hash: str, password: str) -> bool:
    """验证密码与存储的哈希。"""
    try:
        ph.verify(stored_hash, password)
        return True
    except exceptions.VerifyMismatchError:
        return False
    except exceptions.InvalidHashError:
        # 哈希格式无效
        return False

def needs_rehash(stored_hash: str) -> bool:
    """检查密码是否需要重新哈希(参数已更改)。"""
    return ph.check_needs_rehash(stored_hash)

# 使用
password = "user_password_here"

# 注册
hashed = hash_password(password)
print(f"存储的哈希: {hashed}")
# $argon2id$v=19$m=65536,t=2,p=4$...

# 登录
if verify_password(hashed, password):
    print("登录成功")

    # 检查是否应该升级哈希
    if needs_rehash(hashed):
        new_hash = hash_password(password)
        # 用 new_hash 更新数据库

10. 常见误区

误区现实
”有足够的计算能力就能解密哈希”不能。哈希会销毁信息。没有东西可解密。
“SHA-256 适合用于密码存储”SHA-256 太快了。使用 bcrypt/Argon2。
“更长的哈希 = 更安全”安全性取决于算法,而不仅仅是长度。SHA-512 不是 SHA-256 的”两倍安全”。
“我把密码哈希两次来增加安全性”这没有帮助,在某些情况下实际上可能降低安全性。
“MD5 用于非安全目的没问题”直到需求改变。即使”只是校验和”也使用 SHA-256。

11. 本章小结

三点要记住:

  1. 哈希不是加密。 哈希函数设计上是单向的。你不能也不应该期望”解密”一个哈希。没有密钥,没有逆转,只有指纹。

  2. 速度是密码存储的敌人。 像 SHA-256 这样的通用哈希函数被设计成快速的。像 Argon2 这样的密码哈希函数被设计成慢速的。为工作使用正确的工具。

  3. MD5 和 SHA-1 在安全方面已被弃用。 即使”只是校验和”,也优先使用 SHA-256 或 BLAKE3。需求会变,你不希望在它们变化时被抓到使用了破碎的密码学。

12. 下一步

我们已经介绍了加密、哈希及其区别。但我们忽略了一些关键的东西:密钥和盐从哪里来?

在下一篇文章中,我们将探索:随机数——密码学系统中最被低估的组件,以及为什么 rand() 会杀死你的安全性。