Hugo 实现页面加密
引言
众所周知,Hugo 是世界上最好的静态博客生成器之一。通常使用 Leaf Bundle 组织单篇文章的静态资源,一篇文章的目录结构如下:
content/posts/secret-note/
├── index.md
├── photo.webp
├── screenshots/
│ └── result.webp
└── files/
└── note.pdf
渲染后的静态页面:
content/posts/secret-note/
├── index.html
├── photo.webp
├── screenshots/
│ └── result.webp
└── files/
└── note.pdf
我想要实现对 Hugo 的指定页面或文章进行密码加密,如果仅使用 CSS 隐藏内容并通过 JavaScript 控制显示,HTML 正文及相关静态资源依然可以被绝对路径直接访问,无法起到保护作用。
因此,我们需要在 Hugo 站点构建环节引入 密封 (Packaging & Encryption) 步骤。Hugo 正常渲染 Markdown 与短代码,随后构建脚本读取 Front Matter 中标记为加密的页面,将生成的 HTML 正文与相关静态资源打包并加密为单个 .plb 文件。访客输入正确密码后,由浏览器解密并还原为原始内容。

加解密流程
在构建 Hugo 静态网站的 CI/CD 构建流水线中,完整的处理流程如下:
- Hugo 生成初始候选站点;
- 扫描并收集标记为加密的文章正文及静态资源;
- 将正文与资源打包,使用基于密码派生的密钥进行加密;
- 清理候选目录中的明文资源,仅保留解锁界面与加密 Payload;
- 执行泄漏检查,确认无误后发布上线,页面访问时由浏览器解密还原。
候选站点在密封前包含明文内容,仅作为临时构建产物。密封或检查步骤一旦失败,构建过程将立即终止,防止明文内容意外发布。
实现思路
「将大象塞进冰箱」,只需要三步:
- 打开冰箱
- 塞进大象
- 关上冰箱门
静态资源打包 Payload
将 HTML 正文与关联文件按顺序写入连续二进制数据块,并通过 Manifest 清单记录各元素的字节偏移量、长度及 MIME 类型。打包结构定义如下:
manifest = {
html: { offset, length },
files: [{ url, name, mime, offset, length }, ...]
}
plaintext = manifest_length
+ encode(manifest)
+ html_bytes
+ file_bytes...
Manifest 作为索引结构,用于浏览器解密后按偏移量切分并提取 HTML 正文与各类静态文件。Manifest 本身打包在明文数据内部,避免暴露附件名称与结构信息。
密钥派生与数据加密
用户输入的密码通过 PBKDF2-HMAC-SHA-256 派生 256 位密钥,并采用 AES-256-GCM 进行对称加密:
salt = random_bytes()
iv = random_bytes()
key = PBKDF2_HMAC_SHA256(password, salt, iterations, 256_bits)
aad = "hugo-protected:v1:" + canonical_route
ciphertext, tag = AES_256_GCM_ENCRYPT(key, iv, plaintext, aad)
其中:
salt和iv每次加密时随机生成,并随加密文件公开保存;- PBKDF2 的迭代次数用于增加穷举破解的计算成本;
- AES-GCM 认证标签提供机密性与完整性校验。密码错误、数据损坏或认证数据不匹配均会导致解密失败;
- AAD 是附加认证数据,将文章的规范路由作为 AAD 参与计算(例如
/posts/secret-note/)。若密文被修改到其他路径,或公开 Header 中的路由被篡改,校验将直接失败。
文章的规范路由作为附加认证数据 AAD 参与计算。例如密文原本属于 /posts/secret-note/,将它复制到另一个页面后,浏览器检查到路由不同便会拒绝解锁;如果有人修改公开 Header 中的路由,AES-GCM 的认证标签也无法通过校验。
最终生成的 .plb 文件结构如下:
PLB1 | header_length | public_header | ciphertext | authentication_tag
公开 Header 包含格式版本、KDF 参数、迭代次数、salt、iv 及文章路由;密码、Manifest、正文及资源文件均保存在密文中。
清理明文内容
Hugo 渲染后,构建脚本将提取到的明文数据加密,并清理原始明文资源:
candidate_site = hugo_build(source)
for page in find_protected_pages(candidate_site):
config, rendered_html = read_build_data(page)
resources = collect_files(page.output_directory)
plaintext = pack(rendered_html, resources)
encrypted_payload = encrypt(plaintext, config.password, config.route)
write_atomically(page / "protected-content.plb", encrypted_payload)
keep_password_shell(page / "index.html")
remove(resources)
remove_build_data(page)
verify(candidate_site)
publish(candidate_site)
最终生成的加密文章目录仅保留两个文件:
posts/secret-note/
├── index.html
└── protected-content.plb
浏览器中内容解密与还原
访客访问页面时,浏览器下载 protected-content.plb,解析 Header 并校验当前路由,随后派生密钥并解密:
encrypted = download_payload()
header, ciphertext_and_tag = parse(encrypted)
assert normalize(header.route) == normalize(current_route)
key = PBKDF2_HMAC_SHA256(input_password, header.salt, header.iterations)
aad = "hugo-protected:v1:" + header.route
plaintext = AES_256_GCM_DECRYPT(key, header.iv, ciphertext_and_tag, aad)
html, files = unpack(plaintext)
AES-GCM 的认证特性,无需额外存储密码哈希。解密成功即代表密码正确;密码错误或数据损坏均直接判定为解锁失败。
解密完成后,浏览器为提取的静态资源创建临时 blob: 类型的 URL,并替换 HTML 正文中 <img>、srcset、内联样式及附件链接。这些 URL 仅在当前页面会话内有效,页面刷新后自动销毁。
正文插入 DOM 后,构建脚本会触发自定义事件,重新初始化图片查看器、代码高亮、Mermaid 图表等前端组件。

构建和发布流程
我的 Hugo 站点构建与发布流程在 Woodpecker CI 中完成。在 Hugo 完成初始站点生成后,执行密封处理脚本:
// Hugo 渲染候选站点(中间产物,包含明文)
const candidateSite = buildWithHugo();
for (const page of findProtectedPages(candidateSite)) {
// 读取构建元数据与关联资源
const { password, route, html } = readProtectedBuildData(page);
const resources = collectBundleResources(page.outputDirectory);
// 打包明文并生成随机盐值
const plaintext = pack({ html, resources });
const salt = randomBytes();
const iv = randomBytes();
const key = deriveKeyFromPassword(password, salt);
// 加密 Payload,引入路由作为 AAD 绑定
const payload = encryptAndAuthenticate({
key,
plaintext,
associatedData: route,
publicHeader: { salt, iv, route },
});
writeFile(page.outputDirectory, "protected-content.plb", payload);
replaceWithPasswordShell(page);
removePlaintextResources(page.outputDirectory);
removeProtectedBuildData(page);
}
assertNoPlaintextOrLeaks(candidateSite);
publish(candidateSite);
安全校验
最终发布前,校验程序会严格检查以下项目:
- 解锁页
index.html中未残留明文正文、构建标记或密码信息; - 加密页面目录下不存在未经加密的资源文件;
- Payload Header 中的路由与实际发布路由完全一致;
- RSS、Sitemap 及搜索索引中已过滤受保护页面的正文与摘要。
安全局限性
由于缺乏服务端鉴权机制,任何访客均可直接下载 .plb 文件并进行离线爆破。访客解密后仍可通过控制台复制文本、保存 Blob 资源或截图,无法实现离线后的权限撤销。此外,尽管 PBKDF2 增加了单次计算成本,其安全性仍取决于密码本身的复杂度。建议使用高强度的随机密码。
但是,对于无需严格服务端鉴权、仅希望对特定内容保持适度隐蔽性的 Hugo 静态博客而言,这个效果我已经很满意了。
测试效果
打开 Hugo 加密 Demo 测试 即可体验。