副标题:项目日记与月度报告 · 独立开发日记
本文为 vergex.cn 原创首发,转载请注明出处。
上周整理自己密码管理器小项目时,我发现三年前写的加密模块里赫然躺着 iterations=10000 和一把写死在代码里的 AES 密钥。那一刻才意识到:用户输入的主密码根本没有真正派生出一个靠谱的加密密钥。PBKDF2 + Fernet 的组合听起来很稳,但魔鬼全在参数和流程细节里。下面把我重新实现时验证过的写法和踩过的坑记录下来,完整可运行的 CLI + SQLite 版本见站点下载。
1. 主密码 ≠ 加密密钥:先分清楚两个概念
很多人写密码管理器时的第一反应是:把用户主密码当作“密钥”直接 AES 加密。这个思路有两个致命问题。第一,用户主密码的熵往往不够高,直接当密钥意味着有效密钥空间被主密码的字符组合限制。第二,主密码需要被“输入”和“校验”,如果它同时承担登录鉴权和数据加密两个职责,一旦鉴权逻辑被绕过,整个保险库就会直接暴露。
正确的做法是引入密钥派生函数(KDF):主密码只负责“打开保险箱”,KDF 负责把主密码变成真正随机的加密密钥。PBKDF2 就是这里最常用的派生函数之一,它通过大量哈希迭代把低熵输入拉伸成固定长度的密钥。关键是,派生出的密钥只存在于内存,绝不写入磁盘;数据库里只存 salt、迭代次数和密文。
另外,如果你的项目还需要“登录验证”,请用另一套机制(比如 Argon2id 计算密码哈希),不要和保险库加密共用同一个值。把鉴权与加密解耦,才能避免一个功能出错拖垮整个安全模型。
2. PBKDF2 的参数,现在该这么配
PBKDF2 并不是调个函数就完事,它的参数直接决定抗暴力破解能力。以 Python cryptography 库当前版本为例,配置如下:
import os, base64
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(), # 哈希算法
length=32, # 输出密钥长度,单位字节
salt=os.urandom(16), # 盐,至少 16 字节
iterations=600_000, # 迭代次数
)
key = kdf.derive(b"user master password")
迭代次数 iterations 是抵御暴力破解的核心。 OWASP 2023 建议 PBKDF2-HMAC-SHA256 至少 600,000 次。这个数字会随硬件算力提升而增长,所以不要把阈值写死成几年前教程里的 100000。length=32 是因为 Fernet 需要 32 字节原始密钥;salt 必须用密码学安全随机源生成,os.urandom 在主流系统上直接调用 /dev/urandom 或等效 API,足够安全。
还有一个细节:PBKDF2 的 iterations 是线性耗时的,只增加 CPU 成本,不增加内存成本。如果项目对安全等级要求极高,建议后续迁移到 Argon2id;但在合规要求强制使用 PBKDF2 的场景(比如某些企业加密规范),把迭代次数拉到当前推荐值是最低要求。
3. Fernet 到底保护了什么,没保护什么
Fernet 是 cryptography 库提供的高层封装,本质上做两件事:用 AES-128-CBC 加密明文,再用 HMAC-SHA256 做认证。它保证的是保密性和完整性——攻击者无法读出内容,也无法在不知道密钥的情况下篡改密文。
但 Fernet 不是万能的。它的 token 格式是公开的:version(1B) + timestamp(8B) + IV(16B) + ciphertext + HMAC(32B)。这意味着:
- 它不隐藏数据长度:明文越长,密文越长。
- 它默认验证时间戳:
decrypt(token, ttl=...)会在超时后抛出InvalidToken,CLI 工具里如果不希望时间戳限制,请显式传ttl=None。 - 它没有 Associated Data(AEAD):如果你需要把“这条记录属于哪个用户”也纳入认证,只能把它嵌入明文或使用更底层的 AES-GCM。
因此,不要把 Fernet 当成能解决所有问题的黑盒。Fernet 适合加密“一条完整的密码记录”,但不适合直接加密整个数据库文件并期望它隐藏所有元数据。 我在项目里把每条账号记录单独 Fernet 加密,而不是加密整个 JSON 文件,原因就在于此。
4. 一段可直接运行的最小实现
下面这段代码把 PBKDF2 派生和 Fernet 加密串起来,已经可以直接复制运行(需要先安装 pip install cryptography):
import os
import base64
import json
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
class Vault:
VERSION = 1
DEFAULT_ITER = 600_000
SALT_LEN = 16
def __init__(
self,
master_password: str,
salt: bytes | None = None,
iterations: int | None = None,
):
self.salt = salt if salt is not None else os.urandom(self.SALT_LEN)
self.iterations = iterations if iterations is not None else self.DEFAULT_ITER
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=self.salt,
iterations=self.iterations,
)
raw_key = kdf.derive(master_password.encode("utf-8"))
fernet_key = base64.urlsafe_b64encode(raw_key)
self._fernet = Fernet(fernet_key)
def encrypt_entry(self, plaintext: str) -> str:
record = {
"v": self.VERSION,
"alg": "pbkdf2-sha256",
"salt": base64.b64encode(self.salt).decode("ascii"),
"iter": self.iterations,
"ct": self._fernet.encrypt(plaintext.encode("utf-8")).decode("ascii"),
}
return json.dumps(record, separators=(",", ":"))
@classmethod
def decrypt_entry(cls, master_password: str, record_json: str) -> str:
record = json.loads(record_json)
salt = base64.b64decode(record["salt"])
vault = cls(
master_password,
salt=salt,
iterations=record["iter"],
)
plaintext = vault._fernet.decrypt(record["ct"].encode("ascii"))
return plaintext.decode("utf-8")
if __name__ == "__main__":
master = "correct horse battery staple"
vault = Vault(master)
record = vault.encrypt_entry("site:example.com\nuser:alice\npass:S3cr3t!")
print("stored record:", record)
recovered = Vault.decrypt_entry(master, record)
print("recovered:", recovered)
这段代码的关键在于:加密时把 salt、iter、v、alg 一并序列化;解密时再原样读回来重建 Vault 实例。这样即使未来升级迭代次数或算法,旧记录依然能解密。完整版见站点,包含 SQLite 持久化、版本迁移和 CLI 交互。
5. 避坑一:别把 KDF 输出直接塞进 Fernet
这是初学者最容易踩的坑。PBKDF2HMAC.derive() 返回的是 32 字节原始二进制,而 Fernet 的构造函数接收的 key 必须是 base64-urlsafe 编码后的 32 字节,也就是 43 个字符、以 = 结尾的可打印字符串。如果你这样写:
# 错误示范
raw_key = kdf.derive(password.encode())
fernet = Fernet(raw_key) # ValueError: Fernet key must be 32 url-safe base64-encoded bytes.
直接就会报错。正确做法是:
raw_key = kdf.derive(password.encode("utf-8"))
fernet_key = base64.urlsafe_b64encode(raw_key)
fernet = Fernet(fernet_key)
另一个相关坑是混淆 base64.b64encode 和 base64.urlsafe_b64encode。存储 salt 时用标准 base64 没关系,因为 salt 只是字节;但 Fernet key 必须用 urlsafe 变体,否则 / 和 + 会被错误解码。
最后,不要把 fernet_key 写入日志、配置文件或数据库。它只应该存在于 Vault 实例的生命周期内。数据库里永远只存 salt、iterations 和 ciphertext。
6. 避坑二:迭代次数和盐不要“全局共享”
第二个常见错误是把 salt 和 iterations 写死成全局常量,只存一份在用户表甚至配置文件里。这样做的问题在于:
- 无法安全升级迭代次数:如果明年推荐值涨到 100 万,旧记录没有自己的
iter字段,你就无法区分哪些记录需要重新加密。 - 共享 salt 降低安全性:同一个用户下的所有密码条目如果共用同一个 salt,攻击者拿到数据库后可以对这批记录统一做预计算,削弱 salt 本应提供的“防御批量彩虹表”作用。
我在项目里的做法是给每条密码记录都独立生成 salt,并把 v / alg / salt / iter / ct 打包成一条 JSON。升级策略是“读时迁移”:解密旧记录时读取它自带的 iter,如果低于当前 DEFAULT_ITER,就用新参数重新加密后写回。这样用户无感知,安全强度也能逐步提升。
def maybe_migrate(self, record_json: str) -> str:
record = json.loads(record_json)
if record.get("iter", 0) < self.DEFAULT_ITER:
plaintext = Vault.decrypt_entry(self._master, record_json)
return Vault(self._master).encrypt_entry(plaintext)
return record_json
永远把加密参数和密文绑定在一起存储,这是可维护密码管理器的基本习惯。
7. 数据库字段设计:推荐与反推荐对比
很多人纠结“数据库里到底该存几列”。我的经验是:不要只存一个 blob,而是把算法元数据显式结构化。下面是三种常见方案的对比。
| 方案 | salt 位置 | 迭代次数 | 升级能力 | 主要风险 |
|---|---|---|---|---|
| 全局固定 | 代码/配置文件 | 硬编码 | 无 | 一旦泄露,批量彩虹表攻击 |
| 每用户一个 salt | 用户表字段 | 硬编码 | 需全表重写 | 无法平滑升级,旧数据强度固定 |
| 每条记录独立 salt+版本 | 记录内 JSON | 记录内字段 | 读时重加密即可迁移 | 推荐;仅需要注意 JSON 字段合法性 |
推荐的数据库行结构大致如下:
{
"v": 1,
"alg": "pbkdf2-sha256",
"salt": "aGVsbG8...",
"iter": 600000,
"ct": "gAAAAAB..."
}
在 SQLite 里,我把它存成一个 TEXT 列,另一列存未加密的“标签”用于搜索。注意:搜索字段不要包含密码信息,也绝不要用 Fernet 密文的前缀做索引,那会暴露 token 结构。如果你需要按网站域名查找,可以让用户先输入主密码,解密后在前端过滤;或者单独维护一个由用户命名的、无敏感信息的别名列。
另外,如果项目需要“主密码是否正确”的即时反馈,请用另一个专门用于鉴权的 Argon2id 哈希,不要复用保险库派生逻辑。这样可以避免在登录时就暴露加密参数的迭代路径。
结尾:反模式清单与核心总结
写密码管理器时,下面这些事不要碰:
- 把用户主密码直接当 AES 密钥使用。
- PBKDF2 迭代次数低于当前推荐值(如 10k、100k)。
- 所有用户或所有记录共用同一个 salt。
- 数据库里只存 ciphertext,不存 salt、iterations 和版本号。
- 把派生出的 Fernet key 写入日志、配置或持久化存储。
- 用 Fernet token 的前缀做搜索索引或分组。
- 把“登录鉴权”和“保险库解密”混用同一个派生结果。
- 忽视主密码本身的熵,PBKDF2 只能拖慢破解,不能挽救弱口令。
核心总结:PBKDF2 负责把弱主密码“拉”成强密钥,Fernet 负责把单条记录安全地密封起来;真正决定安全性的,是你有没有把 salt、迭代次数和版本号与密文绑定存储,并在未来能够无感升级。

