跳转到主要内容
依人相的月光集市
← 返回首页2026-04-10· 约 5 分钟

博客文章加密机制详解:从对称加密到密钥管理

为什么需要加密?

在 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 密钥用,原因:

  1. 长度不对(AES-256 需要恰好 32 字节 = 256 位)
  2. 熵太低(密码通常只包含字母数字,容易被暴力破解)

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)──→ 密文

工作流程

加密时:

  1. 生成随机的内容密钥(CEK,32 字节)
  2. 用 CEK 加密文章明文 → 密文
  3. 用用户密码加密 CEK → encrypted_key
  4. 用主恢复密钥加密 CEK → recovery_key
  5. encrypted_keyrecovery_key、密文都存起来

正常解密(浏览器端):

  1. 用户输入密码
  2. 用密码解密 encrypted_key → 得到 CEK
  3. 用 CEK 解密密文 → 明文

忘记密码时恢复:

  1. 用主恢复密钥解密 recovery_key → 得到 CEK
  2. 设定新密码,用新密码重新加密 CEK → 新的 encrypted_key
  3. 密文本身不需要重新加密!

改密码:

  1. 用旧密码解密 encrypted_key → CEK
  2. 用新密码加密 CEK → 新 encrypted_key
  3. 只更新 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(旧格式 → 新格式,一次性操作)

设计思考:为什么不能让"密码不参与加密"?

一个常见的想法是:密码只做验证,验证通过后直接展示内容。但在纯静态站点上这行不通:

  1. 没有服务器:GitHub Pages 只提供静态文件,无法在服务端验证密码
  2. 源码公开:如果解密密钥写死在 JavaScript 里,任何人查看源码就能拿到
  3. 安全等于零:密码只是一层"心理障碍",没有真正的加密保护

所以在静态站点上,密码必须参与加密/解密过程。主密钥方案在保持这一安全性的同时,通过 CEK 中间层解决了忘记密码的问题。

与其他系统的对比

系统 密钥管理方式 忘记密码
我们的旧方案 密码直接派生密钥 无法恢复
我们的新方案(CEK) 随机 CEK + 密码/主密钥双重加密 主密钥恢复
LUKS(Linux 磁盘加密) 随机主密钥 + 最多 8 个密钥槽 任一密钥槽恢复
1Password 主密码 + Secret Key 双因子 Account Recovery Key
iPhone 硬件密钥 + 密码 iCloud 恢复

本质思路是一样的:真正加密数据的密钥是随机的,人类密码只是"开锁"的方式之一

总结

概念 一句话解释
AES-256-CBC 用 256 位密钥加密数据的算法,安全性极高
PBKDF2 把人类密码变成固定长度密钥的函数,故意设计得很慢
Salt 随机值,让相同密码产生不同密钥
IV 随机值,让相同内容产生不同密文
CEK 内容加密密钥,随机生成,真正加密文章的密钥
主恢复密钥 你保管的备份钥匙,用来在忘记密码时恢复
密钥槽 用不同密码加密同一个 CEK,实现"多把钥匙开同一把锁"