密码管理器怎么加密:PBKDF2 + Fernet 实践

上周整理自己密码管理器小项目时,我发现三年前写的加密模块里赫然躺着 iterations=10000 和一把写死在代码里的 AES 密钥。那一刻才意识到:用户输入的主密码根本没有真正派生出一个靠谱的加密密钥。PBKDF2 + Fernet 的组合听起来很稳,但魔鬼全在参数和流程细节里。下面把我重新实现时验证过的写法和踩过的坑记录下来,完整可运行的 CLI +

副标题:项目日记与月度报告 · 独立开发日记

本文为 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、迭代次数和版本号与密文绑定存储,并在未来能够无感升级。

技术深度解析

vLLM 显存与吞吐实测

2026-10-11 15:35:53

技术深度解析

如何大规模应用ADOBE ILLUSTRAT:深度解析与实操指南

2026-10-11 18:22:02

0 条回复 A文章作者 M管理员
VergeX|科技前沿
    暂无讨论,说说你的看法吧
❯
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索