Astro 静态博客文章加密功能的实现思路

1649 字
8 分钟
Astro 静态博客文章加密功能的实现思路

背景#

静态博客没有服务端,无法做传统的登录鉴权。但有时候我们确实需要让某些文章仅对知道密码的人可见。

核心思路很简单:构建时把文章 HTML 加密成密文写入页面,访客输入密码后在浏览器端解密。页面源码中不存在任何明文,密码也不会发送到任何服务器。

这个功能使用 Claude Code 协助完成。

演示文章:Firefly 文章加密

整体架构#

graph LR subgraph 构建时 Node.js A[Markdown/MDX] --> B[Astro 渲染 HTML] B --> C[AES-256-GCM 加密] C --> D[Base64 密文] end D -- 网络传输 --> E subgraph 运行时 浏览器 E[访客输入密码] --> F[PBKDF2 派生密钥] F --> G[AES-GCM 解密] G --> H[注入 DOM 显示] end

加密算法#

选择 AES-256-GCM,原因:

  • AES-256 足够安全
  • GCM 模式自带认证(auth tag),能检测密文是否被篡改
  • Web Crypto API 原生支持,无需引入第三方库

密钥派生使用 PBKDF2(SHA-256,100,000 次迭代),从用户密码派生出 256 位密钥。

数据格式#

Base64( salt[16] + iv[12] + authTag[16] + ciphertext )

解密时按固定偏移切分即可。

构建时加密#

加密工具 crypto-utils.ts 只有一个导出函数:

import { createCipheriv, createHmac, pbkdf2Sync } from "node:crypto";
function deriveBytes(key: string, context: string, length: number): Buffer {
return createHmac("sha256", key)
.update(context).digest().subarray(0, length);
}
export function encryptContent(
html: string, password: string, slug: string
): string {
// 确定性 salt/iv —— 相同输入产生相同密文
const salt = deriveBytes(password, `salt:${slug}`, 16);
const iv = deriveBytes(password, `iv:${slug}`, 12);
const key = pbkdf2Sync(password, salt, 100000, 32, "sha256");
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([
cipher.update(html, "utf8"), cipher.final()
]);
return Buffer.concat([
salt, iv, cipher.getAuthTag(), encrypted
]).toString("base64");
}

为什么 salt 和 IV 是确定性的?#

通常加密应使用随机 salt/IV,但这里有特殊原因:Astro 的 dev server 在 HMR 时会重新渲染页面,如果每次 salt/IV 不同,产生的密文就不同,而 sessionStorage 中缓存的密码是用旧密文解密的——密文变了,缓存就失效了,开发时每次热更新都要重新输密码。

使用 HMAC-SHA256 从 password + slug 派生 salt/IV,保证相同输入始终产生相同密文,同时不同文章之间的 salt/IV 仍然不同。

桥接组件#

EncryptedPost.astro 是核心桥接组件:

---
const rawHtml = await Astro.slots.render("default");
const encryptedData = encryptContent(rawHtml, password, slug);
---
<div id="encrypted-container"
data-encrypted={encryptedData}
data-slug={slug}>
<!-- 锁定 UI:密码输入框 -->
<div id="password-ui">...</div>
<!-- 解密后的内容注入到这里 -->
<div id="decrypted-content" class="hidden"></div>
</div>

关键点是 Astro.slots.render("default")——它在构建时把 slot 中的所有内容(Markdown 正文、赞助块、许可证块)渲染成 HTML 字符串,然后加密。最终页面只包含 Base64 密文。

客户端解密#

使用 <script is:inline> 内联脚本,不依赖任何框架:

async function decrypt(pwd) {
var raw = base64ToBytes(encryptedData);
var salt = raw.slice(0, 16);
var iv = raw.slice(16, 28);
var authTag = raw.slice(28, 44);
var ciphertext = raw.slice(44);
// GCM 要求 ciphertext + authTag 拼接
var combined = new Uint8Array(ciphertext.length + 16);
combined.set(ciphertext);
combined.set(authTag, ciphertext.length);
var keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(pwd),
'PBKDF2', false, ['deriveKey']
);
var key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt,
iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, ['decrypt']
);
var decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv }, key, combined
);
return new TextDecoder().decode(decrypted);
}

为什么不用 Svelte 组件?#

最初的实现用了 Svelte 5 组件 + client:load 水合。但遇到了一个棘手的问题:Astro 水合过程中,Svelte 5 的 $state 在异步回调(如 onMount 中的 sessionStorage 自动解密)中的赋值无法触发 DOM 更新。F5 刷新后组件状态混乱,按钮无法点击。

最终改为纯 <script is:inline> + 原生 DOM 操作,彻底消除了框架水合带来的问题。

内容注入的陷阱#

解密后通过 innerHTML 注入 HTML,但有两个问题需要处理:

1. Script 标签不执行#

innerHTML 注入的 <script> 标签是惰性的,浏览器不会执行它们。需要手动重建:

var scripts = contentEl.querySelectorAll('script');
for (var i = 0; i < scripts.length; i++) {
var old = scripts[i];
var ns = document.createElement('script');
// 复制所有属性
for (var j = 0; j < old.attributes.length; j++) {
ns.setAttribute(old.attributes[j].name, old.attributes[j].value);
}
ns.textContent = old.textContent;
old.parentNode.replaceChild(ns, old);
}

这对 Mermaid 图表的渲染脚本至关重要。

2. Mermaid 单例阻塞#

Mermaid 的渲染脚本有单例检查 window.mermaidInitialized。解密后重建脚本时,需要先重置这个标志:

if (typeof window.mermaidInitialized !== 'undefined') {
window.mermaidInitialized = false;
}

3. TOC 目录重新初始化#

解密前 DOM 中没有文章内容,TOC 自然是空的。解密后需要通知 TOC 组件重新扫描标题:

setTimeout(function() {
document.dispatchEvent(new CustomEvent('password:decrypted'));
}, 100);

侧边栏 TOC 和悬浮 TOC 都监听了这个事件,收到后重新初始化。

4. Fancybox 灯箱重新绑定#

Fancybox 在页面初始化时通过 Fancybox.bind() 绑定图片点击事件。解密后新注入的图片不在初始 DOM 中,自然没有被绑定,点击无法打开灯箱。解决方式同样是监听 password:decrypted 事件,延迟重新调用 setup()

document.addEventListener("password:decrypted", () => {
setTimeout(setup, 200);
});

加密范围#

不是整个页面都加密,需要精确控制范围:

内容处理方式
文章正文加密
赞助/分享块加密(放在 slot 内)
许可证块加密(放在 slot 内)
标题、元数据不加密(文章列表也可见)
封面图不加密
评论区隐藏(加密文章不渲染评论组件)
TOC 目录解密前隐藏,解密后动态生成
RSS仅输出标题和描述,不渲染正文

[...slug].astro 中通过条件渲染实现:

{entry.data.password ? (
<EncryptedPost password={entry.data.password} slug={entry.id}
hint={entry.data.passwordHint}>
<Markdown><Content /></Markdown>
<!-- 赞助块、许可证块也放在这里一起加密 -->
</EncryptedPost>
) : (
<> 正常渲染 </>
)}
<!-- 评论:加密文章不显示 -->
{entry.data.comment && !entry.data.password && <Comment />}

会话缓存#

密码缓存使用 sessionStorage,按文章 slug 存储:

// 解密成功后缓存
sessionStorage.setItem('pw:' + slug, pwd);
// 页面加载时尝试自动解密
var cached = sessionStorage.getItem('pw:' + slug);
if (cached) {
decrypt(cached).then(showContent)
.catch(function() { sessionStorage.removeItem(cacheKey); });
}
  • 同一标签页会话内刷新无需重复输入
  • 关闭浏览器后缓存自动清除
  • 密码错误(如文章密码被修改)时自动清除缓存

使用方式#

在文章 frontmatter 中添加两个字段即可:

---
title: 私密文章
published: 2025-01-01
password: "your-password"
passwordHint: "可选的密码提示"
---
这里的所有内容都会被加密。

安全性说明#

这个方案的安全性取决于密码强度。需要明确的是:

  • 密文在页面源码中公开可见,任何人都可以拿到
  • 安全性完全依赖 AES-256-GCM + PBKDF2 的密码学强度
  • 弱密码(如 123456)理论上可以被暴力破解
  • 适合隐私保护场景,不适合高安全性需求

对于静态博客来说,这已经是不依赖服务端能做到的最好方案之一了。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
Astro 静态博客文章加密功能的实现思路
https://blog.cuteleaf.cn/posts/dev-notes/astro-blog-encrypted-post/
作者
夏叶
发布于
2026-02-25
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
夏叶
Hello, I'm XIAYE.
公告
欢迎来到我的博客,从2025年起,将会使用AI对文章进行润色。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
58
分类
7
标签
39
总字数
34,520
运行时长
0
最后活动
0 天前

目录