月白.
← 返回文章
AI 应用笔记·16 分钟阅读

静态博客加密文章实践:从 MDX 到浏览器本地解密

在没有账号系统和数据库的 Next.js 静态博客中,用 PBKDF2 与 AES-GCM 保护单篇文章正文。

我想在静态博客里放几篇不完全公开的文章:它们仍然出现在文章列表中,标题、摘要、分类和标签可以被看见,但正文只有拿到密码的人才能阅读。

这个网站部署在 GitHub 和 Vercel,没有账号系统,也没有数据库。如果只是给页面套一个前端密码框,正文仍会原样出现在 HTML、JavaScript 或接口响应里,用“查看源代码”就能绕过。于是我最终采用了发布前加密正文、浏览器本地解密的方式。

这篇文章记录方案的取舍、核心代码、统一密码的换密流程,以及它真正能保护什么、不能保护什么。

先定义目标

我给这套方案设定了四个目标:

  1. GitHub 仓库、Vercel 构建产物和页面 HTML 中不出现正文明文。
  2. 文章的公开元数据仍能参与列表、分类和标签页生成。
  3. 读者输入密码后,正文只在当前浏览器中解密,不需要把密码提交给服务器。
  4. 不为了少量私密文章引入账号、数据库和后台管理系统。

它的发布和阅读流程可以概括为:

本地 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,所以密文与派生密钥不会相同。

更换统一密码不能只修改本地密码文件。旧密文仍然是用旧密码加密的,直接覆盖密码会让网站上的文章全部无法解开。正确流程是:

  1. 用旧密码解密所有 .mdx.enc
  2. 为每篇文章生成全新的盐和 IV;
  3. 用新密码重新派生密钥并加密;
  4. 用新密码逐篇解密验证;
  5. 所有文章都验证成功后,才替换统一密码和密文;
  6. 中途失败则恢复原文件。

本项目把新密码临时写入被 Git 忽略的 .secrets/new-blog-password.txt,然后运行:

pnpm password:change

脚本完成批量重加密后,会把新密码暂存文件恢复成提示文字。真正的密码不会写进文章、源码、Git、命令行参数或聊天记录。

统一密码至少应使用 12 个以上字符,最好由密码管理器生成并单独备份。由于密文完全公开,攻击者可以下载后无限次离线猜测;前端限流、按钮冷却和隐藏 URL 都阻止不了这种攻击。

“密码正确但文章没有显示”怎样排查

这类问题不能只盯着输入框。一次完整排查应该沿着同一份密文逐层验证:

  1. **先在 Node.js 中解密真实密文。**如果失败,检查密码、盐、IV、迭代次数和认证标签的拆分方式。
  2. **再用浏览器 Web Crypto 解密。**Node 与浏览器的参数名不同,但算法、字节序列和编码必须完全一致。
  3. **确认认证标签的位置。**Node.js 常把 tag 单独设置,Web Crypto 通常接收末尾包含 tag 的完整 payload。
  4. **检查 Base64 与 UTF-8。**不要把 Base64 字符串本身当成密文字节,也不要用错误编码恢复中文。
  5. **检查输入清理。**从密码管理器复制时容易带入换行或首尾空格;本项目会对输入执行 trim()
  6. **把“解密”和“渲染”分开测试。**解密成功后,再确认状态更新、Markdown 渲染器和样式没有吞掉正文。
  7. **确认运行环境。**Web Crypto 通常要求 HTTPS 安全上下文;本地开发的 localhost 是常见例外。

最有效的回归测试不是只调用一个加密函数,而是创建一篇不含敏感内容的临时加密文章,从真实页面输入临时密码,确认解锁、中文、标题、列表和错误提示都正常,再删除测试文章。

这套方案的安全边界

它适合“希望搜索引擎和普通访问者看不到正文,但愿意与少数人共享一个密码”的内容。它不适合医疗记录、身份材料、商业机密等高价值数据。

使用前应该接受这些事实:

  • 标题、摘要、日期、分类、标签、密文长度和访问 URL 都是公开的;
  • 拿到密码的人可以复制明文、转发密码或离线保存全文;
  • 无法知道具体是谁读过文章,也无法只撤销某一位读者;
  • 弱密码会遭受离线字典攻击,600,000 次迭代只能提高成本,不能拯救弱密码;
  • 密码丢失且没有明文备份时,正文应被视为不可恢复;
  • 站点脚本若被攻击,输入的密码和解密结果仍可能泄露。

如果需求升级到“每个人有独立账号”“可以撤销某人的权限”“需要审计阅读记录”,就应该迁移到服务端认证和授权,而不是继续给前端密码框打补丁。

最后得到的经验

这次实践最重要的不是记住某几个 API,而是把安全目标拆开:源文件加密、内容加密、身份认证和访问授权解决的是不同问题。

对于一个纯静态个人博客,浏览器本地解密是维护成本与阅读体验之间的实用折中。它成立的前提是:明文从不进入公开构建链路、密码足够强、盐和 IV 每次随机生成、加密结果经过验证,并且站长和读者都清楚这不是一套真正的权限系统。

参考资料