Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

为什么你不该「自己实现加密算法」

| , 9 minutes reading.

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

你理解 AES。你读过 RSA。你知道数学是有效的。那为什么不自己实现呢?

因为密码学是唯一一个 99% 正确意味着 100% 失败 的领域。

一个比特的时序差异。一个未检查的错误条件。一个可预测的随机数。这些中的任何一个都可以把你的”安全”系统变成攻击者的欢迎垫。

2. 根本问题

密码学没有部分分数

普通软件:
- 错误 → 错误输出 → 用户抱怨 → 你修复它
- 可见的、可调试的、可修复的

密码学软件:
- 错误 → 看起来正确 → 攻击者利用 → 数据泄露
- 静默的、不可见的、灾难性的

加密可能在你所有的测试中都"完美工作"
同时以你看不到的方式完全被破解。

为什么聪明人仍然失败

密码学安全取决于:
1. 数学正确性(简单的部分)
2. 实现正确性(困难的部分)
3. 环境正确性(不可见的部分)

你可以在 #1 上得满分,但在 #2 和 #3 上完全失败。

3. 历史灾难

案例 1:PlayStation 3 ECDSA 失败(2010)

索尼做了什么:
- 使用 ECDSA 签名游戏(正确的算法)
- ECDSA 要求每个签名使用随机 nonce k
- 索尼对每个签名使用相同的 k

数学:
signature = (r, s) 其中 s = k⁻¹(hash + privateKey × r)

两个使用相同 k 的签名:
s₁ = k⁻¹(hash₁ + privateKey × r)
s₂ = k⁻¹(hash₂ + privateKey × r)

相减:
s₁ - s₂ = k⁻¹(hash₁ - hash₂)
k = (hash₁ - hash₂) / (s₁ - s₂)

一旦你有了 k:
privateKey = (s₁ × k - hash₁) / r

结果:
- PS3 主私钥被提取
- 任何人都可以签名"官方"游戏
- 整个安全模型崩溃
- 索尼损失数十亿

案例 2:Debian OpenSSL 灾难(2008)

发生了什么:
- Debian 维护者删除了"未初始化内存"警告
- 删除了两行看起来像 bug 的代码
- 实际上删除了唯一的熵源

"修复":
// 之前(正确但触发警告)
MD_Update(&m, buf, j);  // 使用未初始化内存作为熵
MD_Update(&m, buf, j);

// 之后(损坏)
// 行被删除因为 Valgrind 抱怨

结果:
- 2 年内所有在基于 Debian 系统上生成的密钥
- 只能有 ~32,768 个可能的值(15 位熵)
- 而不是 2^128 种可能性
- 两年的 SSL 证书、SSH 密钥被泄露
- 需要大规模撤销和重新生成

案例 3:Cryptocat 加密缺陷(2013)

Cryptocat 做了什么:
- 构建加密聊天应用
- 在 JavaScript 中实现自己的加密
- 在 ECC 实现中犯了一个错误

错误:
// 为椭圆曲线生成随机值
// 使用 Math.random() 而不是 crypto.getRandomValues()
var random = Math.floor(Math.random() * max);

Math.random() 的特性:
- 不是密码学安全的
- 给定足够的样本是可预测的
- 不同实现有不同的周期

结果:
- 私钥可以被预测
- 所有"加密"消息都可以被解密
- 依赖它的活动家和记者被暴露

案例 4:WEP Wi-Fi 加密(1997-2004)

WEP 设计缺陷:
1. 24 位 IV(初始化向量)太短
   - 只有 1600 万个可能的 IV
   - 在繁忙的网络上重用是不可避免的
   - 相同的 IV + 相同的密钥 = 相同的密钥流

2. IV 以明文发送
   - 攻击者知道 IV
   - 可以收集具有相同 IV 的数据包
   - 将它们异或在一起以消除密钥流

3. CRC32 用于完整性(不是密码学的)
   - 攻击者可以修改数据包
   - 在不知道密钥的情况下重新计算 CRC
   - 没有数据包来源的认证

4. RC4 中的密钥调度弱点
   - 某些 IV 泄露密钥位
   - 收集约 40,000 个带有弱 IV 的数据包
   - 统计恢复密钥

时间线:
1997:WEP 标准化
2001:第一个实用攻击发布
2004:几分钟内完成完整密钥恢复
2007:攻击只需几秒钟

教训:
- 短 IV 保证重用
- CRC 不是 MAC
- RC4 有统计偏差
- "足够好"的安全性不够好

4. 实现攻击

时序攻击

# 脆弱:早期退出暴露密码长度/正确性
def check_password_bad(input_password, stored_hash):
    if len(input_password) != len(stored_hash):
        return False  # 暴露长度!

    for i in range(len(input_password)):
        if input_password[i] != stored_hash[i]:
            return False  # 暴露第一个不匹配的位置!

    return True

# 时序差异:
# 错误长度:~100ns
# 第一个字符错误:~150ns
# 第二个字符错误:~200ns
# ...攻击者可以逐个字符推断密码


# 安全:常量时间比较
import hmac
def check_password_good(input_password, stored_hash):
    return hmac.compare_digest(
        input_password.encode(),
        stored_hash.encode()
    )
# 无论不匹配发生在哪里,始终需要相同的时间

填充预言攻击

带有 PKCS#7 填充的 CBC 模式加密:

┌─────────────────────────────────────────────────────────┐
│ 明文块在加密前被填充                                     │
│                                                         │
│ "HELLO" → "HELLO\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b"   │
│           (11 字节的填充值 0x0b)                        │
└─────────────────────────────────────────────────────────┘

攻击:
1. 向服务器发送修改的密文
2. 服务器解密,检查填充
3. 如果服务器对"填充错误"和"数据错误"返回不同的错误...
4. 攻击者可以逐字节解密整个消息!

脆弱的响应模式:
- "填充错误" vs "解密失败"(不同的消息)
- 400 Bad Request vs 500 Internal Error(不同的状态码)
- 快速响应 vs 慢速响应(时序差异)

任何可观察的差异都能启用攻击。

著名受害者:
- ASP.NET(2010):微软的 Web 框架
- Java Server Faces(2010)
- Ruby on Rails(2013)
- 许多 TLS 实现

侧信道攻击

侧信道通过以下方式泄露信息:

1. 时序
   - 操作需要多长时间
   - 缓存命中/未命中模式
   - 分支预测

2. 功耗
   - 不同的操作使用不同的功率
   - 可以用示波器测量
   - 智能卡特别脆弱

3. 电磁辐射
   - CPU 发出无线电信号
   - 信号随操作变化
   - 可以从几米外测量

4. 声音
   - 计算机对不同操作发出不同的声音
   - RSA 密钥从笔记本电脑冷却风扇提取
   - 是的,真的(2013 年研究)

5. 错误消息
   - 不同条件的不同错误
   - 填充预言是侧信道
   - "无效用户名" vs "无效密码"

6. 缓存时序
   - 内存访问模式
   - Spectre/Meltdown 利用了这一点
   - 影响所有现代 CPU

5. 为什么即使是专家也会失败

OpenSSL 心脏出血漏洞(2014)

// 简化的脆弱代码
struct heartbeat_message {
    uint8_t type;
    uint16_t payload_length;  // 攻击者控制!
    uint8_t payload[];
};

// 错误:信任用户提供的长度
void process_heartbeat(struct heartbeat_message *msg) {
    // 根据声称的长度分配响应缓冲区
    response = malloc(msg->payload_length);

    // 复制声称数量的字节
    memcpy(response, msg->payload, msg->payload_length);

    // 发送响应
    send(response, msg->payload_length);
}

// 攻击:
// 攻击者发送:payload_length = 65535,实际 payload = 1 字节
// 服务器从内存复制 65535 字节(大部分不是 payload)
// 服务器发回 65535 字节包括:
//   - 私钥
//   - 会话 cookie
//   - 密码
//   - 其他用户的数据

// 这在生产 OpenSSL 中存在了 2 年
// 由经验丰富的安全开发人员编写
// 被许多人审查
// 仍然被遗漏

教训

OpenSSL 是:
- 由密码学专家编写
- 开源(许多审查者)
- 广泛部署(经过实战测试)
- 仍然有一个关键漏洞存在 2 年

如果 OpenSSL 专家错过缓冲区溢出,
你凭什么认为你能发现时序攻击?

6. 正确的方法

使用成熟的库

# 不要实现 AES
# 使用经过审计的库

# Python: cryptography 库
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)

# 库处理:
# - 常量时间操作
# - 正确的随机数生成
# - 内存安全
# - 侧信道抵抗
# - 标准的正确实现

使用高级 API

# 不要自己组合原语

# 错误:DIY 认证加密
def encrypt_bad(key, plaintext):
    iv = os.urandom(16)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    # 填充?HMAC?顺序?你会搞错的。
    ciphertext = cipher.encrypt(pad(plaintext))
    mac = hmac.new(key, ciphertext, sha256).digest()
    return iv + ciphertext + mac

# 正确:使用处理一切的 AEAD
from cryptography.fernet import Fernet

key = Fernet.generate_key()
f = Fernet(key)
token = f.encrypt(plaintext)
# Fernet 处理:密钥派生、IV、加密、认证

当你必须使用底层时

# 如果你绝对必须使用底层原语:

# 1. 使用 hazmat 模块(名字就是警告!)
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

# 2. 严格遵循标准(RFC、NIST)
# 3. 从专业人士那里获得安全审计
# 4. 假设你犯了错误
# 5. 准备好事件响应

7. 加密代码中的危险信号

警告标志

# 🚨 危险信号:自定义加密算法
def my_encrypt(data, key):
    result = []
    for i, byte in enumerate(data):
        result.append(byte ^ key[i % len(key)])
    return bytes(result)
# 这是用重复密钥的异或。自 1800 年代以来就已被破解。

# 🚨 危险信号:使用 ECB 模式
cipher = AES.new(key, AES.MODE_ECB)  # ECB 几乎从不正确

# 🚨 危险信号:使用 MD5 或 SHA1 用于安全
hash = hashlib.md5(password).hexdigest()  # 已破解
hash = hashlib.sha1(password).hexdigest()  # 已弃用

# 🚨 危险信号:使用 random 而不是 secrets
import random  # 不是密码学安全的
key = bytes([random.randint(0, 255) for _ in range(32)])

# 🚨 危险信号:用 == 比较密钥
if token == expected:  # 时序攻击!
    grant_access()

# 🚨 危险信号:重用 nonce/IV
iv = b"constant_iv_1234"  # 每次加密必须唯一!

# 🚨 危险信号:加密但不认证
ciphertext = aes_encrypt(key, plaintext)  # 没有完整性检查!

# 🚨 危险信号:"我改进了算法"
def improved_aes(data, key):
    # 加入我自己的变化...
    # 不要。停下。你在削弱它。

8. 你应该做什么

决策树

你需要加密吗?

├─ 用于静态数据?
│   └─ 使用你平台的安全存储
│      - iOS Keychain
│      - Android Keystore
│      - Windows DPAPI
│      - 云 KMS

├─ 用于传输中的数据?
│   └─ 使用 TLS
│      - 不要自己实现
│      - 使用你语言的标准库
│      - 让基础设施处理它

├─ 用于密码?
│   └─ 使用密码哈希
│      - Argon2id
│      - bcrypt
│      - 永远不要加密,始终哈希

├─ 用于令牌/会话?
│   └─ 使用成熟的库
│      - JWT 库(小心使用)
│      - 会话管理框架
│      - OAuth/OIDC 库

└─ 用于自定义的东西?
    └─ 咨询密码学专家
       - 获得专业审计
       - 使用成熟的构建块
       - 准备好它可能是错误的

值得信任的库

通用:
- libsodium (NaCl) - 易于使用,难以误用
- OpenSSL/BoringSSL - 经过实战测试(尽管有 bug)

Python:
- cryptography - 现代、维护良好
- PyNaCl - libsodium 的 Python 绑定

JavaScript:
- Web Crypto API - 浏览器内置
- noble-* 库 - 经过审计、现代

Go:
- crypto/* - 标准库,优秀
- golang.org/x/crypto - 扩展算法

Rust:
- ring - 源自 BoringSSL
- RustCrypto - 纯 Rust 实现

9. 本章小结

三点要记住:

  1. 密码学实现是不可原谅的。 一个时序差异、一个可预测的位、一个未检查的错误——你的安全就没了。算法可以是完美的,而实现是损坏的。

  2. 即使是专家也经常失败。 OpenSSL、索尼、Debian——尽管有专家审查,都有密码学失败。你不比整个安全社区更聪明。

  3. 使用成熟的、经过审计的库。 唯一的获胜方式是不玩这个游戏。使用经过密码学专家审查、在生产中测试、并经受住攻击的库。

10. 下一步

理解为什么不要实现加密是第一步。但即使使用正确的库,系统仍然会失败。为什么?

在下一篇文章中:加密 ≠ 安全——密码学正确但其他一切都坏了的系统级失败。