Astro 静态博客文章加密功能的实现思路
背景
静态博客没有服务端,无法做传统的登录鉴权。但有时候我们确实需要让某些文章仅对知道密码的人可见。
核心思路很简单:构建时把文章 HTML 加密成密文写入页面,访客输入密码后在浏览器端解密。页面源码中不存在任何明文,密码也不会发送到任何服务器。
这个功能使用 Claude Code 协助完成。
演示文章:Firefly 文章加密
整体架构
加密算法
选择 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-01password: "your-password"passwordHint: "可选的密码提示"---
这里的所有内容都会被加密。安全性说明
这个方案的安全性取决于密码强度。需要明确的是:
- 密文在页面源码中公开可见,任何人都可以拿到
- 安全性完全依赖 AES-256-GCM + PBKDF2 的密码学强度
- 弱密码(如
123456)理论上可以被暴力破解 - 适合隐私保护场景,不适合高安全性需求
对于静态博客来说,这已经是不依赖服务端能做到的最好方案之一了。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!