文章

blog文章加密

blog文章加密

在写博客时,有一些文章并不希望直接公开展示。

目标

在 Chirpy / Jekyll 文章的 Front Matter 中直接加入密码字段:

1
password: "这篇文章对应的密码"

完整示例:

1
2
3
4
5
6
7
8
9
10
11
---
title: blog文章加密_测试
date: 2025-04-03 00:00:00 +0800
last_modified_at: 2025-04-03 00:00:00 +0800
categories: [Meta] # 最多两层
tags: [Meta, Jekyll, Chirpy]
# toc: false # 关闭目录
math: true
mermaid: true # diagram generation
password: "your-strong-password-here"
---

构建完成后,最终发布到 GitHub Pages 的页面里不应该包含这段明文密码,也不应该包含文章正文,只应该包含加密后的密文和密码输入界面。

方案设计

静态网站没有后端,不能像传统网站那样在服务器端校验用户登录状态。

因此,比较可行的方式是:

1
2
3
4
5
6
7
8
9
10
11
Markdown 明文文章
        ↓
Jekyll 构建
        ↓
渲染成 HTML
        ↓
构建阶段用 AES 加密 HTML
        ↓
发布密文页面
        ↓
用户浏览器输入密码后本地解密

页面发布后,GitHub Pages 上只存在:

  • 密文;
  • 加密参数;
  • 解密脚本;
  • 密码输入界面。

只要构建产物中没有泄漏原始正文和 Front Matter 中的密码,普通访问者就无法直接看到文章内容。

适用场景

  • 半公开博客文章;
  • 只想分享给少数人的文章;
  • 临时密码保护的笔记;
  • 不希望搜索引擎直接索引正文的内容;
  • 不想引入后端服务的个人静态博客。

不适合真正高敏感内容

仓库结构

GitHub Free 支持 public repo 的 Pages,而 private repo 的 Pages 需要 Pro / Team / Enterprise 等计划。

Free 账户的方案:使用两个仓库。

1
2
chirpy-blog-source     private
username.github.io     public

结构如下:

1
2
3
4
5
6
7
8
9
10
11
private source repo
  保存 Chirpy 源码
  保存 Markdown 明文文章
  保存加密插件
  保存文章 Front Matter 中的密码
  使用 GitHub Actions 构建站点

public pages repo
  只保存 Jekyll 构建后的 _site 内容
  不保存 Markdown 明文文章
  不保存 Front Matter 密码

Jekyll 插件:构建阶段加密文章

新建插件文件:

1
_plugins/encrypt_posts.rb

内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# frozen_string_literal: true

require "openssl"
require "base64"
require "json"
require "cgi"

module Jekyll
  module EncryptedPosts
    module_function

    def b64(bytes)
      Base64.strict_encode64(bytes)
    end

    def extract_password(post)
      password = post.data["password"]

      unless password.is_a?(String) && !password.strip.empty?
        raise "Encrypted post '#{post.data["title"]}' has invalid password field"
      end

      password.strip
    end

    def encrypt_html(plaintext, password, iterations)
      salt = OpenSSL::Random.random_bytes(16)
      iv = OpenSSL::Random.random_bytes(12)

      key = OpenSSL::PKCS5.pbkdf2_hmac(
        password,
        salt,
        iterations,
        32,
        OpenSSL::Digest::SHA256.new
      )

      cipher = OpenSSL::Cipher.new("aes-256-gcm")
      cipher.encrypt
      cipher.key = key
      cipher.iv = iv

      ciphertext = cipher.update(plaintext) + cipher.final
      tag = cipher.auth_tag

      {
        "v" => 1,
        "alg" => "AES-256-GCM",
        "kdf" => "PBKDF2-HMAC-SHA256",
        "iter" => iterations,
        "salt" => b64(salt),
        "iv" => b64(iv),
        "ct" => b64(ciphertext),
        "tag" => b64(tag)
      }
    end

    def encrypted_shell(payload, title)
      payload_json = JSON.generate(payload)

      <<~HTML
        <div class="encrypted-post" data-encrypted-payload="#{CGI.escapeHTML(payload_json)}">
          <div class="encrypted-post-card">
            <h1>#{CGI.escapeHTML(title.to_s)}</h1>
            <p>这篇文章已加密。请输入密码查看内容。</p>

            <form class="encrypted-post-form">
              <input
                type="password"
                class="encrypted-post-password"
                placeholder="Password"
                autocomplete="current-password"
                required
              />
              <button type="submit">解锁</button>
            </form>

            <p class="encrypted-post-error" hidden>密码错误,或文章数据已损坏。</p>
          </div>
        </div>

        <script src="/assets/js/encrypted-post.js"></script>
      HTML
    end
  end
end

Jekyll::Hooks.register [:posts], :post_convert do |post|
  password = post.data["password"]

  next unless password.is_a?(String) && !password.strip.empty?

  site = post.site
  iterations = (site.config["encrypt_iterations"] || 310_000).to_i

  plaintext_html = post.content || post.output

  unless plaintext_html.is_a?(String) && !plaintext_html.strip.empty?
    raise "Encrypted post '#{post.data["title"]}' has no rendered content to encrypt"
  end

  password_value = Jekyll::EncryptedPosts.extract_password(post)

  payload = Jekyll::EncryptedPosts.encrypt_html(
    plaintext_html,
    password_value,
    iterations
  )

  encrypted_html = Jekyll::EncryptedPosts.encrypted_shell(
    payload,
    post.data["title"]
  )

  post.content = encrypted_html
  post.output = encrypted_html

  post.data["description"] = "这篇文章已加密,需要密码查看。"
  post.data["excerpt"] = "这篇文章已加密,需要密码查看。"
end

_config.yml 配置

_config.yml 中增加:

1
encrypt_iterations: 310000

这里的 encrypt_iterations 是 PBKDF2 的迭代次数。

数值越高,暴力破解成本越高,但浏览器端解密也会更慢。

前端解密脚本

新建文件:

1
assets/js/encrypted-post.js

内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
(function () {
  function b64ToBytes(b64) {
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);

    for (let i = 0; i < bin.length; i++) {
      bytes[i] = bin.charCodeAt(i);
    }

    return bytes;
  }

  async function deriveKey(password, salt, iterations) {
    const enc = new TextEncoder();

    const baseKey = await crypto.subtle.importKey(
      "raw",
      enc.encode(password),
      "PBKDF2",
      false,
      ["deriveKey"]
    );

    return crypto.subtle.deriveKey(
      {
        name: "PBKDF2",
        salt,
        iterations,
        hash: "SHA-256"
      },
      baseKey,
      {
        name: "AES-GCM",
        length: 256
      },
      false,
      ["decrypt"]
    );
  }

  async function decryptPayload(payload, password) {
    const salt = b64ToBytes(payload.salt);
    const iv = b64ToBytes(payload.iv);
    const ct = b64ToBytes(payload.ct);
    const tag = b64ToBytes(payload.tag);

    const combined = new Uint8Array(ct.length + tag.length);
    combined.set(ct, 0);
    combined.set(tag, ct.length);

    const key = await deriveKey(password, salt, payload.iter);

    const plaintext = await crypto.subtle.decrypt(
      {
        name: "AES-GCM",
        iv
      },
      key,
      combined
    );

    return new TextDecoder().decode(plaintext);
  }

  async function unlock(container, password) {
    const payloadText = container.getAttribute("data-encrypted-payload");
    const payload = JSON.parse(payloadText);

    const html = await decryptPayload(payload, password);

    container.outerHTML = html;

    if (window.MathJax && window.MathJax.typesetPromise) {
      window.MathJax.typesetPromise();
    }

    if (window.mermaid && window.mermaid.run) {
      window.mermaid.run();
    }
  }

  document.addEventListener("DOMContentLoaded", function () {
    document.querySelectorAll(".encrypted-post").forEach(function (container) {
      const form = container.querySelector(".encrypted-post-form");
      const input = container.querySelector(".encrypted-post-password");
      const error = container.querySelector(".encrypted-post-error");

      form.addEventListener("submit", async function (event) {
        event.preventDefault();
        error.hidden = true;

        try {
          await unlock(container, input.value);
        } catch (err) {
          error.hidden = false;
        }
      });
    });
  });
})();

本地测试

本地构建:

1
JEKYLL_ENV=production bundle exec jekyll build

检查 _site 是否残留正文:

1
grep -R "加密文章正文里的独特关键词" _site

检查 _site 是否残留密码:

1
grep -R "文章 Front Matter 里的密码" _site

检查 public repo 是否只包含构建产物:

1
2
git remote -v
git status

确认不要把 Markdown 源文、插件源码中的测试密码、或者 private source repo 的内容推送到 public Pages repo。

关闭私有仓库自己的 Pages 部署

chirpy-blog-source 里检查这些地方:

1
.github/workflows/

如果有类似这些 workflow:

1
uses: actions/pages-deploy

或者:

1
uses: actions/upload-pages-artifact

或者 workflow 名字类似:

1
2
3
pages.yml
jekyll.yml
deploy-pages.yml

备份后删除。

私有仓库不应该执行“部署到本仓库 Pages”的任务。

让公开仓库启用 Pages

进入 username.github.io 仓库:

1
2
3
4
5
6
Settings
  → Pages
    → Build and deployment
      → Source: Deploy from a branch
      → Branch: main
      → Folder: / root

配置私有仓库向公开仓库推送的权限

推荐用 Deploy Key,不要用个人 PAT。

在本地生成一组专用 SSH key:

1
ssh-keygen -t ed25519 -C "github-actions-deploy-pages" -f github-pages-deploy-key

(empty for no passphrase)

会得到两个文件:

1
2
github-pages-deploy-key
github-pages-deploy-key.pub

在 public repo 添加公钥

进入 username.github.io

1
2
3
Settings
  → Deploy keys
    → Add deploy key

填入:

1
2
3
Title: chirpy-blog-source deploy key
Key: github-pages-deploy-key.pub 的内容
Allow write access: 勾选

在 private repo 添加私钥

进入 chirpy-blog-source

1
2
3
4
Settings
  → Secrets and variables
    → Actions
      → New repository secret

添加:

1
2
Name: PAGES_DEPLOY_KEY
Value: github-pages-deploy-key 的完整私钥内容

注意:私钥只放在 private source repo 的 Actions Secret 里,不要提交进仓库。

在私有仓库新增发布 workflow

chirpy-blog-source 新建:

1
.github/workflows/build-and-deploy.yml

内容建议如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
name: "Build and Deploy"

on:
  push:
    branches:
      - main
      - master
    paths-ignore:
      - .gitignore
      - README.md
      - LICENSE

  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: "publish-public-pages"
  cancel-in-progress: true

jobs:
  build-and-publish:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.3
          bundler-cache: true

      - name: Build site
        run: bundle exec jekyll b -d "_site"
        env:
          JEKYLL_ENV: "production"

      - name: Debug _site structure
        run: |
          find _site -type f
          if [ -d "_site/assets/images" ]; then
            ls _site/assets/images
          fi

      - name: Test site
        run: |
          bundle exec htmlproofer _site \
            --disable-external \
            --ignore-urls "/^http:\/\/127.0.0.1/,/^http:\/\/0.0.0.0/,/^http:\/\/localhost/,/^http:\/\//"

      - name: Configure SSH deploy key
        run: |
          mkdir -p ~/.ssh
          echo "$" > ~/.ssh/pages_deploy_key
          chmod 600 ~/.ssh/pages_deploy_key
          ssh-keyscan github.com >> ~/.ssh/known_hosts

      - name: Clone public Pages repository
        run: |
          GIT_SSH_COMMAND="ssh -i ~/.ssh/pages_deploy_key -o IdentitiesOnly=yes" \
          git clone git@github.com:YOUR_USERNAME/YOUR_USERNAME.github.io.git public-site

      - name: Publish _site to public Pages repository
        run: |
          rsync -av --delete \
            --exclude ".git" \
            _site/ public-site/

          cd public-site

          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

          git add -A

          if git diff --cached --quiet; then
            echo "No changes to publish"
            exit 0
          fi

          git commit -m "Deploy encrypted site"
          GIT_SSH_COMMAND="ssh -i ~/.ssh/pages_deploy_key -o IdentitiesOnly=yes" git push origin main

把这一行:

1
git clone git@github.com:YOUR_USERNAME/YOUR_USERNAME.github.io.git public-site

改成真实 GitHub 用户名。

安全边界

这种方案适合静态博客,但它不是完整的权限系统。

可以防止:

  • public 仓库直接泄漏 Markdown 明文;
  • 搜索引擎直接抓取正文;
  • 普通访问者直接查看内容。

不能防止:

  • 知道密码的人复制内容;
  • 弱密码被暴力破解;
  • private source repo 泄漏;
  • Git history 中残留旧密码;
  • 构建环境被攻破;
  • 图片、附件等静态资源单独泄漏;
  • RSS 或搜索索引配置错误导致正文泄漏。

warning

本文由作者按照 CC BY 4.0 进行授权