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 构建流水线中,完整的处理流程如下:

  1. Hugo 生成初始候选站点;
  2. 扫描并收集标记为加密的文章正文及静态资源;
  3. 将正文与资源打包,使用基于密码派生的密钥进行加密;
  4. 清理候选目录中的明文资源,仅保留解锁界面与加密 Payload;
  5. 执行泄漏检查,确认无误后发布上线,页面访问时由浏览器解密还原。

候选站点在密封前包含明文内容,仅作为临时构建产物。密封或检查步骤一旦失败,构建过程将立即终止,防止明文内容意外发布。

实现思路

「将大象塞进冰箱」,只需要三步:

  • 打开冰箱
  • 塞进大象
  • 关上冰箱门

静态资源打包 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 测试 即可体验。