我想在静态博客里放几篇不完全公开的文章:它们仍然出现在文章列表中,标题、摘要、分类和标签可以被看见,但正文只有拿到密码的人才能阅读。
这个网站部署在 GitHub 和 Vercel,没有账号系统,也没有数据库。如果只是给页面套一个前端密码框,正文仍会原样出现在 HTML、JavaScript 或接口响应里,用“查看源代码”就能绕过。于是我最终采用了发布前加密正文、浏览器本地解密的方式。
这篇文章记录方案的取舍、核心代码、统一密码的换密流程,以及它真正能保护什么、不能保护什么。
先定义目标
我给这套方案设定了四个目标:
- GitHub 仓库、Vercel 构建产物和页面 HTML 中不出现正文明文。
- 文章的公开元数据仍能参与列表、分类和标签页生成。
- 读者输入密码后,正文只在当前浏览器中解密,不需要把密码提交给服务器。
- 不为了少量私密文章引入账号、数据库和后台管理系统。
它的发布和阅读流程可以概括为:
本地 MDX 明文
↓ 发布前运行加密脚本
公开元数据 + 随机盐 + 随机 IV + 密文(.mdx.enc)
↓ GitHub / Vercel 只接触这个文件
浏览器加载密文
↓ 读者输入密码
PBKDF2 派生密钥 → AES-GCM 验证并解密 → 渲染 Markdown
这里有一个容易忽略的细节:**加密不是在 Vercel 构建时才发生,而是在明文进入 Git 之前完成。**否则明文仍可能留在 Git 历史或远端构建日志中。
三种“加密博客”不是一回事
在实现前,我比较了三条路线:
| 方案 | 保护的位置 | 阅读体验 | 适合场景 |
|---|---|---|---|
用 age 等工具加密源文件 | Git 仓库 | 读者通常需要额外工具或密钥 | 私人归档、团队协作、备份 |
| 服务端登录与权限校验 | 服务器和数据接口 | 正常登录后阅读 | 多用户、可撤销权限、高敏感内容 |
| 浏览器本地解密静态密文 | 静态文件中的正文 | 打开网页并输入共享密码 | 小型博客、少量半私密内容 |
本项目选择第三种。它保留了静态站点部署简单、成本低的特点,但并不等于真正的用户权限系统。
密码如何变成 AES 密钥
AES-256 需要一个 256 位密钥,而人输入的是长度不固定、强度也不稳定的密码,不能直接拿来当密钥。这里使用 PBKDF2-HMAC-SHA256 进行密钥派生:
密码 + 随机盐 + 600,000 次迭代
↓ PBKDF2-HMAC-SHA256
256 位 AES 密钥
每篇文章加密时都会生成一个新的 16 字节随机盐。盐不需要保密,它的作用是让相同密码在不同文章中派生出不同的密钥,并阻止攻击者复用预计算结果。
OWASP 对新建密码存储系统优先推荐 Argon2id;在必须使用 PBKDF2-HMAC-SHA256 时,其当前建议工作因子是 600,000 次。这里不是在保存登录密码,而是在用密码派生内容加密密钥,但面对的同样是公开密文上的离线猜测,因此借用了这一保守参数。实际项目仍应在目标手机和电脑上测试解锁耗时,并预留日后升级算法和迭代次数的版本字段。
选择 PBKDF2 的现实原因是:它和 AES-GCM 都由浏览器的 Web Crypto API 原生提供,无需把第三方密码学实现打进前端包。
为什么使用 AES-256-GCM
正文使用 AES-256-GCM 加密,每次加密生成一个新的 12 字节 IV。GCM 属于“认证加密”:它不只隐藏内容,还会生成认证标签,用来发现密码错误、密文损坏或内容被篡改。
本项目把 16 字节认证标签追加到密文末尾,再整体转成 Base64。浏览器的 crypto.subtle.decrypt() 接收的正是“密文 + 标签”这段连续数据。
需要特别注意:**同一密钥下绝不能重复使用 GCM 的 IV。**所以每次初次加密和换密重加密都必须重新随机生成 IV,不能从文件名、日期或计数器随手拼一个。
加密文件的 JSON 信封
加密后的 .mdx.enc 不是不可读的二进制文件,而是一个 JSON 信封。结构大致如下:
{
"version": 1,
"slug": "2026-08-20-example",
"metadata": {
"title": "示例文章",
"date": "2026-08-20",
"category": "AI 应用笔记",
"tags": ["Web Crypto"],
"protected": true
},
"encryption": {
"algorithm": "AES-256-GCM",
"kdf": "PBKDF2-HMAC-SHA256",
"iterations": 600000,
"salt": "Base64 编码的随机盐",
"iv": "Base64 编码的随机 IV"
},
"payload": "Base64 编码的密文与认证标签"
}
公开元数据让站点可以照常生成文章卡片、分类页和标签页。它也意味着标题、摘要、日期、分类、标签和 slug 从来不是秘密,敏感信息不能写在这些字段里。
version、算法名和迭代次数跟随密文保存,是为了让将来的读取代码有机会同时兼容新旧格式,而不是把所有参数永远写死在程序里。
Node.js 端:发布前加密
加密脚本读取 MDX frontmatter,只加密正文。下面是省略校验和文件操作后的核心逻辑:
import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
const iterations = 600_000;
const salt = randomBytes(16);
const iv = randomBytes(12);
const key = pbkdf2Sync(password, salt, iterations, 32, "sha256");
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([
cipher.update(markdownBody, "utf8"),
cipher.final(),
]);
const payload = Buffer.concat([ciphertext, cipher.getAuthTag()]);
实际脚本还做了几件重要的事:
- 只接受 frontmatter 中带有
protected: true的文章; - 要求文件名以
yyyy-mm-dd-开头; - 写出密文后立即用相同参数解密一次,逐字节验证结果;
- 只有验证成功才删除博客目录中的明文副本;
- 统一密码保存在被 Git 忽略的
.secrets目录中。
一篇待加密文章可以这样标记:
---
title: "示例文章"
date: "2026-08-20"
category: "AI 应用笔记"
tags:
- "隐私"
protected: true
---
然后运行:
pnpm encrypt:post -- "content/blog/AI 应用笔记/2026-08-20-example.mdx"
成功后目录中留下的是 2026-08-20-example.mdx.enc。明文仍应在受信任且有备份的位置保存;不要把 Git 当成唯一备份,也不要假设删除当前文件就能清除已经提交过的 Git 历史。
浏览器端:输入密码后解密
浏览器先把读者输入的密码导入 Web Crypto,再用信封中的盐和迭代次数派生 AES-GCM 密钥:
const passwordKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password.trim()),
"PBKDF2",
false,
["deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
hash: "SHA-256",
salt: fromBase64(envelope.encryption.salt),
iterations: envelope.encryption.iterations,
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"],
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromBase64(envelope.encryption.iv) },
key,
fromBase64(envelope.payload),
);
解密结果经过 TextDecoder 还原成 Markdown 字符串,再交给 react-markdown 渲染。整个过程中没有“提交密码”的接口;密码只参与当前页面内存中的密钥派生。
不过,“代码里没有主动发送密码”并不代表浏览器环境绝对可信。若站点发生 XSS、第三方脚本或依赖被污染,恶意 JavaScript 仍可能读取输入框或解密后的正文。因此应尽量减少第三方脚本、保持依赖更新,并为站点配置合理的 Content Security Policy。
为什么采用统一密码,以及怎样安全换密
为了降低个人维护成本,本站的所有加密文章使用同一个自定义密码。它的优点是容易记忆和分享,代价则是边界更大:任何拿到密码的人都能解锁全部受保护文章,单篇文章也无法单独撤销访问权。
即使密码相同,每篇文章仍使用独立随机盐和 IV,所以密文与派生密钥不会相同。
更换统一密码不能只修改本地密码文件。旧密文仍然是用旧密码加密的,直接覆盖密码会让网站上的文章全部无法解开。正确流程是:
- 用旧密码解密所有
.mdx.enc; - 为每篇文章生成全新的盐和 IV;
- 用新密码重新派生密钥并加密;
- 用新密码逐篇解密验证;
- 所有文章都验证成功后,才替换统一密码和密文;
- 中途失败则恢复原文件。
本项目把新密码临时写入被 Git 忽略的 .secrets/new-blog-password.txt,然后运行:
pnpm password:change
脚本完成批量重加密后,会把新密码暂存文件恢复成提示文字。真正的密码不会写进文章、源码、Git、命令行参数或聊天记录。
统一密码至少应使用 12 个以上字符,最好由密码管理器生成并单独备份。由于密文完全公开,攻击者可以下载后无限次离线猜测;前端限流、按钮冷却和隐藏 URL 都阻止不了这种攻击。
“密码正确但文章没有显示”怎样排查
这类问题不能只盯着输入框。一次完整排查应该沿着同一份密文逐层验证:
- **先在 Node.js 中解密真实密文。**如果失败,检查密码、盐、IV、迭代次数和认证标签的拆分方式。
- **再用浏览器 Web Crypto 解密。**Node 与浏览器的参数名不同,但算法、字节序列和编码必须完全一致。
- **确认认证标签的位置。**Node.js 常把 tag 单独设置,Web Crypto 通常接收末尾包含 tag 的完整 payload。
- **检查 Base64 与 UTF-8。**不要把 Base64 字符串本身当成密文字节,也不要用错误编码恢复中文。
- **检查输入清理。**从密码管理器复制时容易带入换行或首尾空格;本项目会对输入执行
trim()。 - **把“解密”和“渲染”分开测试。**解密成功后,再确认状态更新、Markdown 渲染器和样式没有吞掉正文。
- **确认运行环境。**Web Crypto 通常要求 HTTPS 安全上下文;本地开发的
localhost是常见例外。
最有效的回归测试不是只调用一个加密函数,而是创建一篇不含敏感内容的临时加密文章,从真实页面输入临时密码,确认解锁、中文、标题、列表和错误提示都正常,再删除测试文章。
这套方案的安全边界
它适合“希望搜索引擎和普通访问者看不到正文,但愿意与少数人共享一个密码”的内容。它不适合医疗记录、身份材料、商业机密等高价值数据。
使用前应该接受这些事实:
- 标题、摘要、日期、分类、标签、密文长度和访问 URL 都是公开的;
- 拿到密码的人可以复制明文、转发密码或离线保存全文;
- 无法知道具体是谁读过文章,也无法只撤销某一位读者;
- 弱密码会遭受离线字典攻击,600,000 次迭代只能提高成本,不能拯救弱密码;
- 密码丢失且没有明文备份时,正文应被视为不可恢复;
- 站点脚本若被攻击,输入的密码和解密结果仍可能泄露。
如果需求升级到“每个人有独立账号”“可以撤销某人的权限”“需要审计阅读记录”,就应该迁移到服务端认证和授权,而不是继续给前端密码框打补丁。
最后得到的经验
这次实践最重要的不是记住某几个 API,而是把安全目标拆开:源文件加密、内容加密、身份认证和访问授权解决的是不同问题。
对于一个纯静态个人博客,浏览器本地解密是维护成本与阅读体验之间的实用折中。它成立的前提是:明文从不进入公开构建链路、密码足够强、盐和 IV 每次随机生成、加密结果经过验证,并且站长和读者都清楚这不是一套真正的权限系统。