博客文章加密机制详解:从对称加密到密钥管理
为什么需要加密?
在 GitHub Pages 这样的纯静态博客上,所有内容都是公开的 HTML 文件。如果想让某些文章(日记、私密想法)只有输入密码才能查看,就需要在发布前加密内容,在浏览器端解密。
旧方案的完整流程(已升级,仅供学习参考)
加密阶段(本地 Node.js 脚本)
你的文章明文
↓
[1] 生成随机 Salt(16 字节)
↓
[2] PBKDF2(密码, Salt, 100000次, SHA-256) → 256位密钥(Key)
↓
[3] 生成随机 IV(16 字节)
↓
[4] AES-256-CBC(明文, Key, IV) → 密文(Base64)
↓
[5] 输出格式:IV的十六进制 + ":" + 密文Base64
↓
[6] Salt 存入 front matter,密文替换正文
↓
推送到 GitHub Pages
解密阶段(浏览器端 JavaScript)
用户输入密码
↓
[1] 从 front matter 读取 Salt
↓
[2] PBKDF2(密码, Salt, 100000次, SHA-256) → 256位密钥(Key)
↓
[3] 从密文中分离 IV 和 Base64 密文
↓
[4] AES-256-CBC.decrypt(密文, Key, IV) → 明文
↓
[5] 用 marked.js 渲染 Markdown → 显示文章
旧方案的致命缺陷
忘记密码 = 永久丢失内容。密码是唯一的解密通道,没有任何恢复手段。
关键概念逐个解释
AES-256-CBC(加密算法)
- AES(Advanced Encryption Standard):目前最广泛使用的对称加密算法
- 256:密钥长度 256 位,安全性极高
- CBC(Cipher Block Chaining):分组加密模式,每个明文块与前一个密文块异或后再加密
- 对称加密:加密和解密用同一个密钥
明文块1 ⊕ IV → AES加密 → 密文块1
明文块2 ⊕ 密文块1 → AES加密 → 密文块2
明文块3 ⊕ 密文块2 → AES加密 → 密文块3
...
为什么叫"对称"?因为加密的钥匙和解密的钥匙是同一把。就像一把门锁,锁和开都用同一把钥匙。
PBKDF2(密码派生函数)
人类能记住的密码(如 mypassword123)不能直接当 AES 密钥用,原因:
- 长度不对(AES-256 需要恰好 32 字节 = 256 位)
- 熵太低(密码通常只包含字母数字,容易被暴力破解)
PBKDF2 的作用是把低熵密码"拉伸"成高质量密钥:
PBKDF2(密码, Salt, 迭代次数, 哈希算法) → 固定长度的密钥
- Salt(盐):随机值,防止相同密码产生相同密钥(防彩虹表攻击)
- 迭代 100000 次:故意让计算变慢,暴力破解每次尝试都要算 10 万次 SHA-256
- 输出:恰好 32 字节的密钥,可以直接给 AES 使用
IV(初始化向量)
- 16 字节随机数,每次加密都不同
- 作用:即使同一篇文章用同一密码加密两次,密文也完全不同
- IV 不需要保密,和密文一起存储
Salt(盐值)
- 16 字节随机数,每篇文章独立生成
- 存储在 front matter 的
salt字段中 - 作用:即使多篇文章用同一密码,派生出的 Key 也不同
安全模型分析
目前方案的安全性
| 项目 | 评估 |
|---|---|
| 加密强度 | AES-256,非常安全 |
| 密钥派生 | PBKDF2 + 10万次迭代,暴力破解成本高 |
| Salt/IV | 每篇文章独立随机生成,无法利用重复 |
| 密码存储 | 不存储密码,只存 Salt |
| 弱点 | 密码由人选择,如果太简单仍可被暴力破解 |
核心限制:忘记密码 = 丢失内容
密码 ──PBKDF2──→ 密钥 ──AES──→ 密文
↑ ↑
│ │
没有密码就无法得到密钥 密文无法逆向得到密钥
这是对称加密的根本特性:密钥是唯一的解密通道。没有密码就无法派生出密钥,没有密钥就无法解密。
当然,由于我们用的是 Git,加密前的明文版本还在 git 历史里(
git log -p -- 文件路径),这是目前唯一的"后门"。
改进方案:主密钥 + 密码双层架构
设计思路
借鉴 LUKS(Linux 磁盘加密)的思路:文章内容和用户密码之间增加一层"内容密钥"。
┌─── 用户密码加密 → encrypted_key(存 front matter)
│
随机内容密钥(CEK) ──┤
│
└─── 主恢复密钥加密 → recovery_key(存 front matter)
文章明文 ←──AES(CEK)──→ 密文
工作流程
加密时:
- 生成随机的内容密钥(CEK,32 字节)
- 用 CEK 加密文章明文 → 密文
- 用用户密码加密 CEK →
encrypted_key - 用主恢复密钥加密 CEK →
recovery_key - 把
encrypted_key、recovery_key、密文都存起来
正常解密(浏览器端):
- 用户输入密码
- 用密码解密
encrypted_key→ 得到 CEK - 用 CEK 解密密文 → 明文
忘记密码时恢复:
- 用主恢复密钥解密
recovery_key→ 得到 CEK - 设定新密码,用新密码重新加密 CEK → 新的
encrypted_key - 密文本身不需要重新加密!
改密码:
- 用旧密码解密
encrypted_key→ CEK - 用新密码加密 CEK → 新
encrypted_key - 只更新 front matter,密文不变
优势
| 对比 | 当前方案 | 主密钥方案 |
|---|---|---|
| 忘记密码 | 无法恢复(除非翻 git 历史) | 用主密钥恢复 |
| 改密码 | 需要解密整篇文章再重新加密 | 只需重新加密 CEK(几十字节) |
| 安全性 | 密码直接决定安全性 | 内容安全性由随机 CEK 保证,更强 |
| 复杂度 | 简单 | 稍复杂,需管理主密钥 |
主恢复密钥的保管
主密钥是一个长随机字符串(如 64 位十六进制),需要安全保管:
- 存入密码管理器(1Password、Bitwarden 等)
- 打印纸质备份放保险箱
- 不要提交到 git 仓库
- 丢失主密钥 = 丢失恢复能力(但还可以用正常密码解密)
前端解密流程对比
当前流程
用户输入密码 → PBKDF2派生Key → AES解密密文 → 显示
❌ 密码错误 → 解密失败 → 提示错误
主密钥方案流程
用户输入密码 → PBKDF2派生Key → 解密encrypted_key → 得到CEK → AES解密密文 → 显示
❌ 密码错误 → 解密encrypted_key失败 → 提示错误
多了一步"解密内容密钥",但对用户来说体验完全一样。
实际实现:代码架构
升级后的加密系统包含以下脚本:
scripts/
├── crypto-utils.js → 公共模块(加解密原语、CEK 操作、文件扫描)
├── init-master-key.js → 生成主恢复密钥
├── encrypt.js → 加密文章(CEK + 双重加密)
├── decrypt.js → 解密文章(支持新旧格式)
├── change-password.js → 改密码(只重加密 CEK)
├── recover-password.js → 忘记密码后用主密钥恢复
└── migrate.js → 旧格式迁移到新格式
加密后的 front matter 字段
---
title: "我的文章"
protected: true
encrypted: true
layout: protected-post
salt: abc123... # 内容加密的 Salt(新格式中仅作标识)
encrypted_key: IV:Base64... # 用户密码加密的 CEK
key_salt: 111aaa... # encrypted_key 的 PBKDF2 Salt
recovery_key: IV:Base64... # 主恢复密钥加密的 CEK
recovery_salt: 222bbb... # recovery_key 的 PBKDF2 Salt
---
{% raw %}IV_hex:Base64_密文{% endraw %}前端兼容逻辑
浏览器端通过检测 encrypted_key 字段是否存在来判断格式:
// 有 encrypted_key → 新格式
if (isNewFormat) {
// 密码 → PBKDF2 → 解密 encrypted_key → 得到 CEK → 解密内容
}
// 没有 encrypted_key → 旧格式
else {
// 密码 → PBKDF2 → 直接解密内容(兼容旧文章)
}完整操作流程
首次使用:
npm run init-master-key → 保存好主恢复密钥
日常写文章:
写文章 → 标记 protected: true → npm run encrypt → git push
编辑加密文章:
npm run decrypt → 编辑 → npm run encrypt → git push
改密码:
npm run change-password(只改 CEK 的加密壳,密文不变)
忘记密码:
npm run recover-password(用主密钥解出 CEK,设新密码)
旧文章迁移:
npm run migrate(旧格式 → 新格式,一次性操作)
设计思考:为什么不能让"密码不参与加密"?
一个常见的想法是:密码只做验证,验证通过后直接展示内容。但在纯静态站点上这行不通:
- 没有服务器:GitHub Pages 只提供静态文件,无法在服务端验证密码
- 源码公开:如果解密密钥写死在 JavaScript 里,任何人查看源码就能拿到
- 安全等于零:密码只是一层"心理障碍",没有真正的加密保护
所以在静态站点上,密码必须参与加密/解密过程。主密钥方案在保持这一安全性的同时,通过 CEK 中间层解决了忘记密码的问题。
与其他系统的对比
| 系统 | 密钥管理方式 | 忘记密码 |
|---|---|---|
| 我们的旧方案 | 密码直接派生密钥 | 无法恢复 |
| 我们的新方案(CEK) | 随机 CEK + 密码/主密钥双重加密 | 主密钥恢复 |
| LUKS(Linux 磁盘加密) | 随机主密钥 + 最多 8 个密钥槽 | 任一密钥槽恢复 |
| 1Password | 主密码 + Secret Key 双因子 | Account Recovery Key |
| iPhone | 硬件密钥 + 密码 | iCloud 恢复 |
本质思路是一样的:真正加密数据的密钥是随机的,人类密码只是"开锁"的方式之一。
总结
| 概念 | 一句话解释 |
|---|---|
| AES-256-CBC | 用 256 位密钥加密数据的算法,安全性极高 |
| PBKDF2 | 把人类密码变成固定长度密钥的函数,故意设计得很慢 |
| Salt | 随机值,让相同密码产生不同密钥 |
| IV | 随机值,让相同内容产生不同密文 |
| CEK | 内容加密密钥,随机生成,真正加密文章的密钥 |
| 主恢复密钥 | 你保管的备份钥匙,用来在忘记密码时恢复 |
| 密钥槽 | 用不同密码加密同一个 CEK,实现"多把钥匙开同一把锁" |