静态站点文章加密实现详解:PBKDF2 + AES-256-GCM 全流程
本站最近上线了文章加密功能——给文章 frontmatter 加一个 password 字段,正文就会以密文形式发布,访客输入密码才能阅读。这篇把整个实现过程完整记录下来:加密算法的选择、构建期如何加密、浏览器端如何解密、以及配套的防泄露处理。
先说明一个重要前提:本站是纯静态站点(Astro 构建产物直接丢 OSS),没有任何服务端。所以加密只能在”构建时”做、解密只能在”浏览器端”做。这意味着它本质上是”防君子不防小人”的伪装加密——后面会专门讨论这个问题。
整体架构
写文章(index.md,含 password 字段)
│
▼
Astro 构建期 ──► LockedContent.astro 加密正文(PBKDF2 + AES-256-GCM)
│ 密文 bundle 写入 HTML
▼
静态 HTML(正文以密文形式存在,页面只有锁表单)
│
▼
浏览器加载页面 ──► 访客输入密码 ──► Web Crypto API 解密 ──► innerHTML 注入正文
关键点:dist 产物里永远没有明文。搜索引擎爬虫、RSS 订阅器、直接看源码的人,拿到的都是密文。
为什么不用 MD5 + AES-CBC
参考过不少静态站加密方案(比如某博客的 Pelican 加密插件),常见做法是:
var key = CryptoJS.MD5(password); // 密码直接 MD5 当密钥
CryptoJS.AES.decrypt(bundle, key, { iv: iv, padding: CryptoJS.pad.NoPadding });
这个方案有两个明显弱点:
- MD5 无 KDF。密码空间有多大,暴力尝试的成本就有多低。现代 GPU 算 MD5 每秒能跑几十亿次,六位数字密码瞬间被扫完;
- AES-CBC 不认证。密码错了也能”解出”一堆乱码,没法区分是密码错了还是内容坏了,交互上只能靠”解出的不是合法 UTF-8”来猜。
所以本站换成了 PBKDF2 + AES-256-GCM:
| 环节 | 选型 | 理由 |
|---|---|---|
| 密钥派生 | PBKDF2(21 万次迭代, SHA-256, 随机 16 字节 salt) | 把单次尝试成本抬高到几十毫秒级,暴力破解需要 21 万倍算力 |
| 加密算法 | AES-256-GCM | 带认证标签,密码错误直接解密失败,不会吐乱码 |
| 随机数 | 每次构建重新生成 salt + IV | 同一篇文章每次构建的密文都不同,防止重放与字典比对 |
为什么不直接用 scrypt/Argon2?因为解密必须跑在浏览器端,Web Crypto API 原生支持 PBKDF2,而 scrypt/Argon2 要么没有原生实现、要么需要引入 WASM 依赖。PBKDF2 + 高迭代已经是”零依赖且够用”的平衡点。
构建期加密:LockedContent.astro
加密逻辑写在一个 Astro 组件里,文章页检测到 password 字段就渲染它。组件的 frontmatter(构建时在 Node 里执行)负责加密:
import { createCipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
const { password = '', content = '' } = Astro.props;
// 1. 随机 salt + IV,每次构建都不同
const salt = randomBytes(16);
const iv = randomBytes(12);
// 2. PBKDF2 派生 256-bit 密钥(21 万次迭代)
const key = pbkdf2Sync(password, salt, 210_000, 32, 'sha256');
// 3. AES-256-GCM 加密
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(content, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag(); // 16 字节认证标签
// 4. bundle = base64(salt) . base64(iv) . base64(密文 || tag)
const bundle = [
salt.toString('base64'),
iv.toString('base64'),
Buffer.concat([ct, tag]).toString('base64'),
].join('.');
组件模板里渲染一个锁表单,并把 bundle 放在隐藏 div 里(供客户端脚本读取),同时注入解密脚本:
<div class="locked-post" data-locked-post>
<div data-locked-bundle hidden>{bundle}</div>
<p>内容已加密,输入密码解锁查看</p>
<form data-locked-form>
<input type="password" data-locked-input placeholder="密码" />
<button type="submit">解锁</button>
</form>
<p data-locked-error hidden>密码错误,请重试</p>
</div>
注意 tag 是拼接在密文末尾一起交给浏览器的——Web Crypto 的 AES-GCM.decrypt 默认按”密文尾部 128 位是标签”解析,和 Node 侧”先密文后 tag”的存储格式正好对上,两端都零配置。
浏览器端解密:Web Crypto API
解密脚本用浏览器原生 crypto.subtle,零外部依赖,不引 crypto-js,也不怕 CDN 挂掉:
const [saltB64, ivB64, dataB64] = bundle.split('.');
const toBuf = (b64) => { // base64 → Uint8Array
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf;
};
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
// 1. 同样的 PBKDF2 派生流程,参数必须与构建期完全一致
const keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: toBuf(saltB64), iterations: 210000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, ['decrypt']
);
// 2. 解密。密码错误时认证失败会直接抛异常
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: toBuf(ivB64) }, key, toBuf(dataB64)
);
// 3. 注入正文,并让 Prism 重新高亮代码块
const host = document.createElement('div');
host.innerHTML = new TextDecoder().decode(plain);
root.replaceWith(host);
// Prism.highlightElement(...) 处理 pre code
} catch (err) {
error.hidden = false; // 密码错误
input.value = '';
input.focus();
}
});
解密流程和构建期加密严格对称,只有一处不需要对称:密码。解密只拿 bundle + 用户输入的密码,salt/IV/迭代次数全部来自页面本身。
接入文章页与防泄露处理
加密不只是”把正文换掉”那么简单——如果其他页面还带着正文摘要,等于没加密。所以配套处理了所有可能泄露正文的地方:
| 位置 | 处理 |
|---|---|
posts/[slug].astro | 有 password 时正文渲染为锁组件,跳过 TOC 提取,meta description 换成”🔒 本文内容已加密” |
RSS(index.xml.ts) | 加密文章的 description 替换为通用提示,不再输出完整正文 |
列表/归档/终端摘要(postSummary) | 返回”🔒 内容已加密,输入密码查看”,不读取正文 |
sitemap | 保留 URL(让文章可以被找到),正文本就不在页面里 |
文章页的判断逻辑很简单:
const isLocked = !!entry.data.password;
const toc = isLocked ? null : extractToc(entry.rendered?.html ?? '');
// ...
{isLocked ? (
<LockedContent password={entry.data.password} content={entry.rendered?.html ?? ''} />
) : (
<Content />
)}
其中 entry.rendered.html 是 Astro 已经把 Markdown 渲染成 HTML、处理完相对路径后的成品,加密的是这个最终 HTML,而不是原始 Markdown——所以图片链接、代码高亮等行为和解密前完全一致。
使用方式
在文章 frontmatter 加一行即可,不需要任何其他操作:
---
title: "测试Astro加密文章"
password: "你的密码"
date: "2026-08-06T10:00:00+08:00"
---
- 构建后正文以密文进入
dist,页面只有锁表单; - 删除
password字段即恢复公开; - 已经可以正常配合 RSS/归档/侧边栏使用(摘要自动替换)。
安全性讨论:它到底能防什么
必须诚实地说清楚边界。静态站 + 客户端解密 = 无法做到真正的保密,原因很直接:
- 密文、算法、盐、迭代次数全部公开。攻击者拿到页面源码就能离线暴力破解,唯一的成本就是 PBKDF2 那 21 万次迭代;
- 密钥最终在浏览器里。只要攻破任意一个访客的浏览器,就能看到解密后的明文,无法做到”只有指定的人能看”;
- 明文仍在 git 仓库里。仓库是私有的没问题;如果仓库公开,加不加密码都一样。
所以它实际提供的是:
| 防护对象 | 效果 |
|---|---|
| 搜索引擎/爬虫索引正文 | ✅ 有效(爬虫不跑 JS,拿不到密文内容) |
| RSS 订阅器看到正文 | ✅ 有效(description 已替换) |
| 直接翻源码的人 | ✅ 有效(只有密文) |
| 拿到 HTML 离线爆破的”有心人” | ⚠️ 取决于密码强度,21 万次 PBKDF2 把单次尝试抬到几十毫秒级,弱密码仍可能被字典击穿 |
| 被社工/被攻破浏览器 | ❌ 无效 |
给使用者的建议:密码务必用长随机字符串(比如 16 位以上),不要用生日、手机号这类可枚举的值。密码强度足够时,PBKDF2 已经能挡住绝大多数”顺手看看”级别的尝试。
验证:构建产物检查
最后一步是在构建后确认真的没有明文残留,这一步建议写进发布流程:
# 以测试文章为例,全站 HTML 里搜正文特征短语,应当零命中
grep -r "正文特征短语" dist/ --include="*.html"
# 加密文章页应当存在锁表单与密文 bundle
grep -o 'data-locked-bundle' dist/posts/<slug>/index.html
本站实测:构建后全站 HTML、RSS、_astro 资源里都搜不到正文特征串,浏览器端用正确密码可以完整还原(含代码高亮),错误密码被 GCM 认证拒绝。
经验总结
- 静态站加密的定位是”防君子不防小人”,别指望它做身份鉴别。要做真正的权限控制,必须有服务端;
- KDF 比加密算法本身更值得花心思。MD5 当密钥的 AES-CBC 方案被秒破不是 AES 的问题,是 KDF 的问题;
- GCM 的认证特性是 UX 加分项——错误密码直接报错,不用靠”乱码检测”猜;
- 加密要配套处理所有出口。正文换了密文,meta description、RSS、摘要、TOC 这些”隐形的正文副本”也得一起处理,否则白加密;
- 构建期加密 + 浏览器原生 Web Crypto 解密,整个链路零 npm 依赖,这是静态站方案里比较省心的组合。