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 或搜索索引配置错误导致正文泄漏。
