Hexo 使用通行密钥对文章加密

Hexo 中主流使用 hexo-blog-encrypt 插件可以对文章进行加密。这种方法通过密码实现。相比更为简单的前端 JavaScript 密码比较验证,该方法的加密流程如下

  • Markdown 文章头部密码 → PBKDF2 派生密钥 → AES-256-CBC 加密原始内容 → 生成 HMAC 完整性校验 → 输出加密的 HTML

从而实现使用加密厚的内容替换原始内容,防止通过简单 JavaScript 手段直接跳过密码验证。同时解密过程则是

  • 用户输入密码 → PBKDF2 派生密钥 → 验证 HMAC → AES-256-CBC 解密 → 获取原始 HTML 内容 → 渲染文章内容

这种方案的好处是简单易用,对于需要保密的文章内容,只需要在使用插件并在文章头部增加密码即可。但是作为一个会点技术的折腾党怎么满足于每次手动输入密码?看着现在各大网站推行的通行密钥,登录只需要触摸一下 TouchID 的快感确实是传统输入密码不能比拟的。所以就在国庆期间好好探索了一下其可行性,最终也是在静态博客中用上了一触即达的通行密钥。

整体思路

混合加密 (Hybrid Encryption)

Q: 为什么使用混合加密而不是直接用 FIDO2 密钥加密?
A: 支持多设备

Q: PRF Salt 必须保密吗?
A: 不需要 因为 PRF Salt 的作用是为了 跨域一致性

// 相同 FIDO2 设备 + 相同 Salt → 相同密钥
wrappingKey = FIDO2_PRF(hardware_key, prfSalt)
↑ ↑
保密的 公开的
  • PRF Salt = 盐值(公开)
  • Hardware Key = 密码(保密)

即使知道 Salt,硬件密钥一般仅存在 FIDO2 设备中无法导出,因此没有硬件密钥仍然无法派生出 wrappingKey

Q: 下载了我的博客 HTML,能破解吗?
A: 不能,原因如下

从 HTML 可以获得:
1. 加密的文章内容 (ciphertext)
2. 初始化向量 (iv, authTag)
3. 包装后的 CEK (wrappedKeys)
4. PRF Salt

不能获得:
包装密钥 (wrappingKey)
└─ 使用 AES-256-GCM 加密,安全性目前尚可

测试页面

测试加密文章

密码: 123456

食用指南

注意: 以下代码只针对 NexT 主题修改,如果使用的不是 NexT 主题则需要自行根据需要针对性修改

站点配置

站点 _config.yml
encryption:
enabled: true # 启用加密
# ---------------------------------------------------------------
# 解密后缓存时长 (分钟)
# 默认 0 表示不缓存,即每次刷新页面后都需要重新认证
# ---------------------------------------------------------------
cache: 10
# ---------------------------------------------------------------
# 64 位盐值
# 可通过 openssl rand -hex 32 生成
# 修改后需要重新注册 FIDO2 设备
# ---------------------------------------------------------------
salt: ""
# ---------------------------------------------------------------
# FIDO2 设备注册后生成的 key (切勿泄露!!)
# 如果注册多个 key 则在下方依次添加即可
# 每添加一个新 key 需要重新 generate 并部署才能生效
# ---------------------------------------------------------------
keys:
- ""
- ""

注册 FIDO2 流程

  • ./source/register.html (新增)

注意: 注册页面中需要填写 站点 _config.yml 中使用的 salt

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>注册通行密钥</title>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
/>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "sans-serif";
background: white;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 60px 20px 20px;
}

.container {
border-radius: 16px;
max-width: 650px;
width: 100%;
padding: 40px;
}

h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
text-align: center;
}

.subtitle {
color: #666;
text-align: center;
margin-bottom: 30px;
font-size: 14px;
}

.register-btn {
width: 100%;
padding: 15px;
background: #059669;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}

.register-btn:hover:not(:disabled) {
background: #047857;
}

.register-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #9ca3af;
}

.register-btn i {
margin-right: 10px;
}

#status {
margin-top: 20px;
display: none;
}

.note {
padding: 15px;
border-radius: 8px;
margin-top: 15px;
}

.note.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}

.note.danger {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}

.note.info {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}

.note code {
display: block;
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
font-size: 12px;
font-family: "Monaco", "Courier New", monospace;
user-select: all;
word-break: break-all;
margin: 10px auto;
text-align: center;
}

.copy-btn {
display: block;
padding: 8px 16px;
background: #0284c7;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin: 10px auto 0;
}

.copy-btn:hover {
background: #0369a1;
}

.copy-btn i {
margin-right: 5px;
}

.salt-container {
margin-bottom: 20px;
}

.salt-container label {
display: block;
margin-bottom: 8px;
color: #666;
font-size: 14px;
}

.salt-container input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>注册通行密钥</h1>
<p class="subtitle">
使用 YubiKey、Touch ID 或其他 FIDO2 设备保护您的加密内容
</p>

<div class="salt-container">
<label for="prf-salt-input"
>PRF Salt <span style="color: #ef4444">*</span></label
>
<input type="text" id="prf-salt-input" placeholder="" required />
</div>

<button class="register-btn" id="register-btn">
<i class="fa fa-key"></i> 开始注册
</button>

<div id="status"></div>
</div>

<script>
function getRPID() {
const hostname = location.hostname;
return hostname === "127.0.0.1" ? "localhost" : hostname;
}

class FIDO2Registrar {
constructor() {
this.btn = document.getElementById("register-btn");
this.saltInput = document.getElementById("prf-salt-input");

this.btn.onclick = () => this.register();
this.saltInput.addEventListener("input", () => this.validateInput());
this.validateInput();
}

validateInput() {
const isValid = this.saltInput.value.trim().length > 0;
this.btn.disabled = !isValid;
}

async register() {
this.btn.disabled = true;

if (!window.PublicKeyCredential) {
this.show(
'<div class="note danger"><p>浏览器不支持 FIDO2/WebAuthn</p></div>'
);
this.btn.disabled = false;
return;
}

this.show('<div class="note info"><p>正在注册通行密钥...</p></div>');

try {
const userId = crypto.getRandomValues(new Uint8Array(32));
const challenge = crypto.getRandomValues(new Uint8Array(32));

const cred = await navigator.credentials.create({
publicKey: {
challenge,
rp: {
name: "Undefined Blog",
id: getRPID(),
},
user: {
id: userId,
name: "Zabrian",
displayName: "Zabrian",
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" },
{ alg: -257, type: "public-key" },
],
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
requireResidentKey: true,
},
timeout: 60000,
extensions: {
prf: {},
},
},
});

if (!cred) throw new Error("生成凭证失败");

const prfEnabled = cred.getClientExtensionResults().prf?.enabled;
if (!prfEnabled) {
this.show('<div class="note danger"><p>PRF 扩展不可用</p></div>');
this.btn.disabled = false;
return;
}

await this.generateEncryptionKey();

this.btn.style.display = "none";
} catch (e) {
let msg = "注册失败: ";
if (e.name === "NotAllowedError") msg += "操作取消";
else if (e.name === "InvalidStateError") msg += "重复注册";
else msg += e.message;

this.show(`<div class="note danger"><p>${msg}</p></div>`);
} finally {
this.btn.disabled = false;
}
}

async generateEncryptionKey() {
try {
const prfSaltString = this.saltInput.value.trim();

this.show(
'<div class="note info"><p>正在生成加密密钥...</p></div>'
);

const challenge = crypto.getRandomValues(new Uint8Array(32));
const prfSalt = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(prfSaltString)
);

const assertion = await navigator.credentials.get({
publicKey: {
challenge,
rpId: getRPID(),
userVerification: "preferred",
timeout: 60000,
extensions: {
prf: {
eval: { first: prfSalt },
},
},
},
});

const prfKey =
assertion.getClientExtensionResults().prf?.results?.first;
if (!prfKey) throw new Error("无法获取 PRF 密钥");

const encryptionKey = new Uint8Array(prfKey);
const encryptionKeyHex = Array.from(encryptionKey)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

this.show(`
<div class="note success">
<p>
<code id="encryption-key">${encryptionKeyHex}</code>
<button class="copy-btn" onclick="copyEncryptionKey(event)">
<i class="fa fa-copy"></i> 复制密钥
</button>
</p>
</div>`);
} catch (e) {
this.show(
`<div class="note danger"><p>生成密钥失败: ${e.message}</p></div>`
);
}
}

show(html) {
document.getElementById("status").innerHTML = html;
document.getElementById("status").style.display = "block";
}
}

function copyEncryptionKey(event) {
const key = document.getElementById("encryption-key").textContent;
navigator.clipboard.writeText(key).then(() => {
const btn = event.target.closest("button");
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa fa-check"></i> 已复制';
setTimeout(() => {
btn.innerHTML = originalText;
}, 2000);
});
}

document.addEventListener("DOMContentLoaded", () => new FIDO2Registrar());
</script>
</body>
</html>

加密流程

  • ./scripts/post-encrypt.js (新增)
const crypto = require("crypto");

const PBKDF2_ITERATIONS = 10000;

function encrypt(text, masterKey) {
const iv = crypto.randomBytes(12);
const keyBuffer = Buffer.from(masterKey, "hex");
const cipher = crypto.createCipheriv("aes-256-gcm", keyBuffer, iv);

let encrypted = cipher.update(text, "utf8", "base64");
encrypted += cipher.final("base64");

return {
ciphertext: encrypted,
iv: iv.toString("base64"),
authTag: cipher.getAuthTag().toString("base64"),
};
}

function encryptCEK(cek, wrapKey) {
const iv = crypto.randomBytes(12);
const keyBuffer = Buffer.from(wrapKey, "hex");
const cipher = crypto.createCipheriv("aes-256-gcm", keyBuffer, iv);

let encrypted = cipher.update(cek);
encrypted = Buffer.concat([encrypted, cipher.final()]);

return {
type: "fido2",
encryptedCEK: encrypted.toString("base64"),
iv: iv.toString("base64"),
authTag: cipher.getAuthTag().toString("base64"),
};
}

function derivePBKDF2Key(password, salt, iterations) {
return crypto.pbkdf2Sync(
Buffer.from(password, "utf8"),
Buffer.from(salt, "utf8"),
iterations,
32,
"sha256"
);
}

function encryptCEKWithPassword(cek, password, salt, iterations) {
const wrapKey = derivePBKDF2Key(password, salt, iterations);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", wrapKey, iv);

let encrypted = cipher.update(cek);
encrypted = Buffer.concat([encrypted, cipher.final()]);

return {
type: "password",
encryptedCEK: encrypted.toString("base64"),
iv: iv.toString("base64"),
authTag: cipher.getAuthTag().toString("base64"),
salt: salt,
};
}

function genTemplate(abbrlink, encData, wrappedKeys, prfSalt, cache) {
const wrappedKeysJson = JSON.stringify(wrappedKeys).replace(/"/g, "&quot;");
const hasFido2 = wrappedKeys.some((k) => k.type === "fido2");
const hasPassword = wrappedKeys.some((k) => k.type === "password");

const fido2Html = hasFido2
? `<button class="decrypt-btn" id="fido2-verify-btn"><i class="fa fa-fingerprint"></i> 通行密钥验证</button>`
: "";

const passwordHtml = hasPassword
? `<div class="password-decrypt-group">
<button class="decrypt-btn" id="show-password-btn"><i class="fa fa-lock"></i> 密码验证</button>
<div class="password-input-group" id="password-input-group" style="display:none">
<input type="password" class="password-input" id="password-input" placeholder="输入密码" />
<button class="decrypt-btn" id="password-decrypt-btn"><i class="fa fa-key"></i> 确定</button>
</div>
</div>`
: "";

return `<div class="encrypted-post-container"
data-ciphertext="${encData.ciphertext}"
data-iv="${encData.iv}"
data-auth-tag="${encData.authTag}"
data-abbrlink="${abbrlink || ""}"
data-wrapped-keys='${wrappedKeysJson}'
data-prf-salt="${prfSalt}"
data-cache="${cache || 0}">
<div class="encrypted-post-notice">
<div class="decrypt-methods">${fido2Html}${passwordHtml}</div>
<div class="verification-status" id="verification-status"></div>
</div>
<div class="decrypted-content" id="decrypted-content" style="display:none"></div>
</div>`.trim();
}

function getKeys(cfg) {
return Array.isArray(cfg.keys) ? cfg.keys : cfg.key ? [cfg.key] : [];
}

function validateConfig(cfg) {
if (!cfg?.enabled) {
hexo.log.warn("加密功能未启用");
return false;
}

if (!cfg.salt || !/^[a-fA-F0-9]{64}$/.test(cfg.salt)) {
hexo.log.error("PRF Salt 配置无效");
return false;
}

const keys = getKeys(cfg);
if (keys.length === 0) {
hexo.log.error("请先注册 FIDO2 通行密钥");
return false;
}

for (const key of keys) {
if (!/^[a-fA-F0-9]{64}$/.test(key)) {
hexo.log.error(`密钥格式无效: ${key}`);
return false;
}
}
return true;
}

hexo.extend.filter.register(
"after_post_render",
function (data) {
if (!data.encrypted) return data;

const cfg = hexo.config.encryption;
if (!validateConfig(cfg)) return data;

try {
const cek = crypto.randomBytes(32);
const encData = encrypt(data.content, cek.toString("hex"));
const wrappedKeys = getKeys(cfg).map((key) => encryptCEK(cek, key));

if (data.password) {
wrappedKeys.push(
encryptCEKWithPassword(
cek,
String(data.password),
String(cfg.salt),
PBKDF2_ITERATIONS
)
);
}

data.content = genTemplate(
data.abbrlink,
encData,
wrappedKeys,
cfg.salt,
cfg.cache
);
hexo.log.info(`Encrypted: ${data.title}`);
} catch (err) {
hexo.log.error(`Encrypted: ${data.title} ${err}`);
}
return data;
},
15
);

解密流程

  • ./source/js/decrypt.js (新增)
  • ./source/_data/styles.styl (新增)
const PBKDF2_ITERATIONS = 500000;

function getRPID() {
const hostname = location.hostname;
return hostname === "127.0.0.1" ? "localhost" : hostname;
}

class Decryptor {
constructor() {
this.container = null;
this.data = null;
this.rpId = getRPID();
}

init() {
this.container = document.querySelector(".encrypted-post-container");
if (!this.container) return;

this.data = {
ciphertext: this.container.dataset.ciphertext,
iv: this.container.dataset.iv,
authTag: this.container.dataset.authTag,
abbrlink: this.container.dataset.abbrlink,
wrappedKeys: JSON.parse(this.container.dataset.wrappedKeys || "[]"),
prfSalt: this.container.dataset.prfSalt,
cache: parseInt(this.container.dataset.cache) || 0,
};

if (this.checkCache()) return;

const fido2Btn = document.getElementById("fido2-verify-btn");
if (fido2Btn) {
if (!window.PublicKeyCredential) {
this.showStatus("浏览器不支持 FIDO2/WebAuthn", "error");
} else {
fido2Btn.onclick = () => this.authenticate();
}
}

const showPasswordBtn = document.getElementById("show-password-btn");
if (showPasswordBtn) {
showPasswordBtn.onclick = () => this.showPasswordInput();
}

const passwordBtn = document.getElementById("password-decrypt-btn");
if (passwordBtn) {
passwordBtn.onclick = () => this.decryptWithPassword();
}

const passwordInput = document.getElementById("password-input");
if (passwordInput) {
passwordInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.decryptWithPassword();
});
}
}

getCacheKey() {
return this.data.abbrlink;
}

showPasswordInput() {
const showBtn = document.getElementById("show-password-btn");
const inputGroup = document.getElementById("password-input-group");
const passwordInput = document.getElementById("password-input");

if (showBtn) showBtn.style.display = "none";
if (inputGroup) inputGroup.style.display = "flex";
if (passwordInput) {
passwordInput.focus();
passwordInput.addEventListener("input", () =>
this.updatePasswordButtonState()
);
}
this.updatePasswordButtonState();
}

updatePasswordButtonState() {
const passwordInput = document.getElementById("password-input");
const passwordBtn = document.getElementById("password-decrypt-btn");

if (!passwordInput || !passwordBtn) return;

const hasPassword = passwordInput.value.trim().length > 0;
passwordBtn.disabled = !hasPassword;
passwordBtn.style.opacity = hasPassword ? "1" : "0.5";
passwordBtn.style.cursor = hasPassword ? "pointer" : "not-allowed";
}

checkCache() {
if (!this.data.cache) return false;

try {
const cached = localStorage.getItem(this.getCacheKey());
if (!cached) return false;

const { html, expired } = JSON.parse(cached);
if (Date.now() < expired) {
this.render(html);
setTimeout(() => this.hideStatus(), 2000);
return true;
}
localStorage.removeItem(this.getCacheKey());
return false;
} catch (e) {
return false;
}
}

saveCache(html) {
if (!this.data.cache) return;

try {
const expired = Date.now() + this.data.cache * 60 * 1000;
localStorage.setItem(
this.getCacheKey(),
JSON.stringify({ html, expired })
);
} catch (e) {}
}

async authenticate() {
this.showStatus("正在验证身份...", "info");

try {
const challenge = crypto.getRandomValues(new Uint8Array(32));

const prfSalt = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(this.data.prfSalt)
);

const assertion = await navigator.credentials.get({
publicKey: {
challenge,
rpId: this.rpId,
userVerification: "preferred",
timeout: 60000,
extensions: { prf: { eval: { first: prfSalt } } },
},
});

const prfResults = assertion.getClientExtensionResults().prf;
if (!prfResults?.results?.first) throw new Error("PRF 扩展不可用");

const wrappingKey = prfResults.results.first;

this.showStatus("验证成功,正在解密...", "success");
setTimeout(() => this.unwrapAndDecrypt(wrappingKey), 300);
} catch (e) {
if (e.name === "NotAllowedError") {
this.showStatus("验证被拒绝", "error");
} else if (e.name === "InvalidStateError" || e.name === "NotFoundError") {
this.showStatus("未注册的通行密钥", "error");
} else {
this.showStatus(`验证失败: ${e.message}`, "error");
}
}
}

async derivePBKDF2Key(password, salt, iterations = PBKDF2_ITERATIONS) {
const passwordBuffer = new TextEncoder().encode(password);
const saltBuffer = new TextEncoder().encode(salt);

const baseKey = await crypto.subtle.importKey(
"raw",
passwordBuffer,
"PBKDF2",
false,
["deriveBits"]
);

const derivedBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: saltBuffer,
iterations: iterations,
hash: "SHA-256",
},
baseKey,
256
);

return derivedBits;
}

async decryptWithPassword() {
const passwordInput = document.getElementById("password-input");
const password = passwordInput?.value.trim();

if (!password) {
this.showStatus("未输入密码", "error");
return;
}

this.showStatus("正在解密...", "info");

try {
const passwordWrapped = this.data.wrappedKeys.find(
(k) => k.type === "password"
);
if (!passwordWrapped) throw new Error("不支持密码认证");

const wrappingKey = await this.derivePBKDF2Key(
password,
passwordWrapped.salt
);
await this.unwrapAndDecrypt(wrappingKey, "password");
} catch (e) {
this.showStatus(`解密失败: ${e.message}`, "error");
}
}

combineWithAuthTag(data, authTag) {
const combined = new Uint8Array(data.byteLength + authTag.byteLength);
combined.set(new Uint8Array(data), 0);
combined.set(new Uint8Array(authTag), data.byteLength);
return combined;
}

async unwrapAndDecrypt(wrappingKey, type = "fido2") {
try {
const wrapKeyBuffer = await crypto.subtle.importKey(
"raw",
wrappingKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
const targetKeys = this.data.wrappedKeys.filter((k) => k.type === type);

let cek = null;
for (const wrapped of targetKeys) {
try {
const encCEK = this.b64ToAB(wrapped.encryptedCEK);
const wrapIV = this.b64ToAB(wrapped.iv);
const wrapAuthTag = this.b64ToAB(wrapped.authTag);
const wrappedCEK = this.combineWithAuthTag(encCEK, wrapAuthTag);

cek = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: wrapIV, tagLength: 128 },
wrapKeyBuffer,
wrappedCEK
);
break;
} catch (e) {}
}

if (!cek)
throw new Error(type === "password" ? "密码错误" : "通行密钥无权限");
await this.decryptContent(cek);
} catch (e) {
this.showStatus(`解密失败: ${e.message}`, "error");
}
}

async decryptContent(decryptionKey) {
try {
this.showStatus("正在解密...", "info");

const ciphertext = this.b64ToAB(this.data.ciphertext);
const iv = this.b64ToAB(this.data.iv);
const authTag = this.b64ToAB(this.data.authTag);
const encData = this.combineWithAuthTag(ciphertext, authTag);

const key = await crypto.subtle.importKey(
"raw",
decryptionKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, tagLength: 128 },
key,
encData
);

const html = new TextDecoder().decode(decrypted);
this.saveCache(html);
this.render(html);
setTimeout(() => this.hideStatus(), 2000);
} catch (e) {
this.showStatus(`解密失败: ${e.message}`, "error");
}
}

render(html) {
const content = document.getElementById("decrypted-content");
const notice = document.querySelector(".encrypted-post-notice");
if (!content || !notice) return;

notice.style.display = "none";
content.innerHTML = html;
content.style.display = "block";

this.renderRefresh();
}

showStatus(msg, type = "info") {
const el = document.getElementById("verification-status");
if (!el) return;
const icons = {
info: "fa-info-circle",
success: "fa-check-circle",
error: "fa-times-circle",
};
el.style.display = "block";
el.className = `verification-status ${type}`;
el.innerHTML = `<i class="fa ${icons[type]}"></i> ${msg}`;
}

hideStatus() {
const el = document.getElementById("verification-status");
if (el) el.style.display = "none";
}

rebuildTOC() {
try {
const content = document.getElementById("decrypted-content");
const tocWrap = document.querySelector(".post-toc-wrap");
const sidebar = document.querySelector(".sidebar-inner");
const utils = window.NexT?.utils;
if (!content || !tocWrap) return;

const headings = content.querySelectorAll("h1,h2,h3,h4,h5,h6");
const ensureToc = () =>
tocWrap.querySelector(".post-toc") ||
(() => {
const el = document.createElement("div");
el.className = "post-toc animated";
tocWrap.appendChild(el);
return el;
})();

if (!headings.length) {
const exist = tocWrap.querySelector(".post-toc");
if (exist) exist.innerHTML = "";
sidebar?.classList.remove("sidebar-nav-active", "sidebar-toc-active");
sidebar?.classList.add("sidebar-overview-active");
utils?.registerSidebarTOC?.();
return;
}

const { roots } = Array.from(headings).reduce(
(acc, h, i) => {
if (!h.id) h.id = `heading-${i}`;
const level = parseInt(h.tagName[1], 10);
const node = {
level,
id: h.id,
text: h.textContent.trim(),
children: [],
};
while (acc.stack.length && acc.stack.at(-1).level >= level)
acc.stack.pop();
(acc.stack.length ? acc.stack.at(-1).children : acc.roots).push(node);
acc.stack.push(node);
return acc;
},
{ roots: [], stack: [] }
);

const render = (nodes, depth = 1) =>
nodes
.map((n) => {
const link = `<a class="nav-link" href="#${n.id}"><span class="nav-text">${n.text}</span></a>`;
const kids = n.children.length
? `<ol class="nav-child">${render(n.children, depth + 1)}</ol>`
: "";
return `<li class="nav-item nav-level-${depth}">${link}${kids}</li>`;
})
.join("");

const toc = ensureToc();
toc.innerHTML = `<ol class="nav">${render(roots)}</ol>`;

sidebar?.classList.add("sidebar-nav-active", "sidebar-toc-active");
sidebar?.classList.remove("sidebar-overview-active");
utils?.registerSidebarTOC?.();
} catch (e) {
console.error(`TOC 重建失败: ${e.message}`);
}
}

renderRefresh() {
this.rebuildTOC();
NexT?.boot?.refresh?.();
}

b64ToAB(b64) {
const str = atob(b64);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
return bytes.buffer;
}
}

const decryptor = new Decryptor();
document.addEventListener("DOMContentLoaded", () => {
if (document.querySelector(".encrypted-post-container")) decryptor.init();
});
.encrypted-post-container
min-height: 300px

.encrypted-post-notice
text-align: center
padding: 40px 20px
background: transparent
border-radius: 12px
margin: 20px 0

.decrypt-methods
display: flex
flex-direction: column
align-items: center
gap: 15px
max-width: 280px
margin: 0 auto

.decrypt-btn
width: 100%
background: #3b8fc7
color: white
border: none
padding: 15px 40px
font-size: 16px
border-radius: 30px
cursor: pointer
transition: all .3s ease
box-shadow: 0 4px 15px rgba(59,143,199,.4)
text-align: center

.decrypt-btn:hover
transform: translateY(-2px)
box-shadow: 0 6px 20px rgba(59,143,199,.6)

.decrypt-btn:active
transform: translateY(0)

.decrypt-btn:disabled
opacity: 0.5
cursor: not-allowed
transform: none

.decrypt-btn:disabled:hover
transform: none
box-shadow: 0 4px 15px rgba(59,143,199,.4)

.decrypt-btn i
margin-right: 8px

.password-decrypt-group
width: 100%
display: flex
flex-direction: column
gap: 10px

.password-input-group
width: 100%
display: flex
flex-direction: column
gap: 10px
animation: fadeIn .3s ease-in

.password-input
width: 100%
padding: 12px 20px
font-size: 15px
border: 2px solid #e0e0e0
border-radius: 25px
outline: none
transition: border-color .3s ease
box-sizing: border-box

.password-input:focus
border-color: #3b8fc7

.verification-status
margin-top: 20px
padding: 12px 20px
border-radius: 8px
font-size: 14px
display: none
max-width: 300px
margin-left: auto
margin-right: auto

.verification-status.info
background: #e7f3ff
color: #0066cc
border: 1px solid #b3d9ff

.verification-status.success
background: #e6f9e6
color: #00aa00
border: 1px solid #b3e6b3

.verification-status.error
background: #ffe6e6
color: #cc0000
border: 1px solid #ffb3b3

.verification-status i
margin-right: 8px

.decrypted-content
animation: fadeIn .5s ease-in

@keyframes fadeIn
from
opacity: 0
transform: translateY(10px)
to
opacity: 1
transform: translateY(0)

.encrypted-post-preview
padding: 20px
background: #f8f9fa
border-radius: 4px
color: #666

.encrypted-status
display: flex
align-items: center
justify-content: center
gap: 8px
font-size: 15px
color: #888

.encrypted-status i
color: #667eea
font-size: 16px

.encrypted-decrypted-preview
animation: fadeIn .5s ease-in
line-height: 1.8
color: #333

.encrypted-lock-icon-index
color: #e74c3c
margin-right: 8px
font-size: 18px
vertical-align: baseline
position: relative
top: -1px
transition: color 0.3s ease

.encrypted-lock-icon-index.decrypted
color: #27ae60

.encrypted-lock-icon-small
color: #e74c3c
margin-right: 6px
font-size: 10px
vertical-align: baseline
position: relative
top: -1px

.encrypted-lock-icon-small.decrypted
color: #27ae60

@media (prefers-color-scheme: dark)
.encrypted-post-notice
background: transparent

.password-input
background: #374151
color: #f3f4f6
border-color: #4b5563

.password-input:focus
border-color: #3b8fc7

.encrypted-post-preview
background: #2d3748
color: #a0aec0

.encrypted-status
color: #9ca3af

.encrypted-status i
color: #9fa8da

.encrypted-decrypted-preview
color: #e5e7eb

.encrypted-lock-icon-index
color: #ff6b6b

.encrypted-lock-icon-index.decrypted
color: #2ecc71

.encrypted-lock-icon-small
color: #ff6b6b

.encrypted-lock-icon-small.decrypted
color: #2ecc71

使用方法

  • encrypted: 可选
    • true 加密该文章
    • false 不加密该文章
  • password: 可选
  • encrypted 控制文章是否加密,使用已注册的安全密钥可以解密所有加密文章。
  • password 增加密码解密的选项,可以每个文章不同。安全密钥或者密码使用其一均可以解密。

一个典型的文章 Markdown 头内容如下

---
title: 文章标题
abbrlink: 12345678
encrypted: true
password: 123456
---

七零八碎

修补 head-unique.njk

因为使用 generate 生成文章 HTML 的时候会生成一些 meta 标签数据,其中会包括 description 信息,如果文章头部没有 description 字段则会使用正文的部分内容作为 meta.description 这样会导致文章部分内容泄露到 HTML 中,所以这里修补一下这里的逻辑,如果是加密文章则不生成 meta 标签

  • ./themes/next/layout/_partials/head/head-unique.njk (修改)
{# 判断文章是否是是加密的,如果不是则生成对应的 meta 标签 #}
{%- if not page.encrypted %}
{{ open_graph() }}
{%- endif %}

{# https://github.com/theme-next/hexo-theme-next/issues/866 #}
{%- set canonical = url | replace(r/index\.html$/, '') %}
{%- if not config.permalink.endsWith('.html') %}
{%- set canonical = canonical | replace(r/\.html$/, '') %}
{%- endif %}
<link rel="canonical" href="{{ canonical }}">

{# Exports some front-matter variables to Front-End #}
{# https://hexo.io/docs/variables.html #}
{{ next_data('page', next_config_unique()) }}

{{ next_data('calendar',
theme.calendar if page.type === 'schedule' else '')
}}

修补 post.njk

加密后的文章在首页预览的时候会显示 文章已加密 的提示字样。同时如果文章已经解密,则正常展示预览部分 (即 <!-- more --> 之前的内容)

同时在首页文章标题前面增加一把锁图标,未解密时是红色锁定图标,已解密变为绿色开锁图标

  • ./themes/next/layout/_macro/post.njk (修改)
{# POST BLOCK 部分的修改 #}
{##################}
{### POST BLOCK ###}
{##################}

{%- if post.header !== false %}
<header class="post-header">
<{% if is_index %}h2{% else %}h1{% endif %} class="post-title{% if post.direction and post.direction.toLowerCase() === 'rtl' %} rtl{% endif %}" itemprop="name headline">
{# Link posts #}
{%- if post.link %}
{%- if post.sticky > 0 %}
<span class="post-sticky-flag" title="{{ __('post.sticky') }}">
<i class="fa fa-thumbtack"></i>
</span>
{%- endif %}
{%- set postTitleIcon = '<i class="fa fa-external-link-alt"></i>' %}
{%- set postText = post.title or post.link %}
{{- next_url(post.link, postText + postTitleIcon, {class: 'post-title-link post-title-link-external', itemprop: 'url'}) }}
{% elif is_index %}
{%- if post.sticky > 0 %}
<span class="post-sticky-flag" title="{{ __('post.sticky') }}">
<i class="fa fa-thumbtack"></i>
</span>
{%- endif %}
{########################################}
{########### 增加对加密文章的判断 ##########}
{########################################}
{%- if post.encrypted %}
<i class="fa fa-lock encrypted-lock-icon-index" id="lock-icon-{{ post.abbrlink }}" data-abbrlink="{{ post.abbrlink }}"></i>
{%- endif %}
{{- next_url(post.path, post.title or __('post.untitled'), {class: 'post-title-link', itemprop: 'url'}) }}
{%- else %}
{{- post.title }}
{{- post_edit(post.source) }}
{%- endif %}
</{% if is_index %}h2{% else %}h1{% endif %}>

<div class="post-meta-container">
{{ partial('_partials/post/post-meta.njk') }}

{%- if post.description and (not theme.excerpt_description or not is_index) %}
<div class="post-description">{{ post.description }}</div>
{%- endif %}
</div>
</header>
{%- endif %}

{# POST BODY 部分的修改 #}
{#################}
{### POST BODY ###}
{#################}
<div class="post-body{% if post.direction and post.direction.toLowerCase() === 'rtl' %} rtl{% endif %}" itemprop="articleBody">
{%- if is_index %}
{%- if post.encrypted %}
<div class="encrypted-post-preview" id="encrypted-preview-{{ post.abbrlink }}" data-abbrlink="{{ post.abbrlink }}">
<div class="encrypted-status">
<i class="fa fa-shield-alt"></i> 文章已加密
</div>
</div>
<div class="encrypted-decrypted-preview" id="decrypted-preview-{{ post.abbrlink }}" style="display:none;"></div>
<!--noindex-->
{%- if theme.read_more_btn %}
<div class="post-button">
<a class="btn" href="{{ url_for(post.path) }}">
{{ __('post.read_more') }} &raquo;
</a>
</div>
{%- endif %}
<!--/noindex-->
<script>
(function () {
const cacheKey = "{{ post.abbrlink }}";
try {
const cached = localStorage.getItem(cacheKey);
if (!cached) return;

const { html, expired } = JSON.parse(cached);
if (Date.now() >= expired) {
localStorage.removeItem(cacheKey);
return;
}

const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
const moreElement = tempDiv.querySelector("#more");

let preview = "";
if (moreElement) {
const range = document.createRange();
range.setStartBefore(tempDiv.firstChild);
range.setEndBefore(moreElement);
preview = range.cloneContents().textContent.trim();
}

if (!preview) {
const text = tempDiv.textContent.trim();
preview = text ? text.substring(0, 200) + (text.length > 200 ? "..." : "") : "暂无预览";
}

const previewEl = document.getElementById("decrypted-preview-{{ post.abbrlink }}");
const encryptedEl = document.getElementById("encrypted-preview-{{ post.abbrlink }}");

if (previewEl) {
previewEl.textContent = preview;
previewEl.style.display = "block";
}
if (encryptedEl) encryptedEl.style.display = "none";
} catch (e) {}
})();
</script>
{% elif post.description and theme.excerpt_description %}
<p>{{ post.description }}</p>
<!--noindex-->
{%- if theme.read_more_btn %}
<div class="post-button">
<a class="btn" href="{{ url_for(post.path) }}">
{{ __('post.read_more') }} &raquo;
</a>
</div>
{%- endif %}
<!--/noindex-->
{% elif post.excerpt %}
{{ post.excerpt }}
<!--noindex-->
{%- if theme.read_more_btn %}
<div class="post-button">
<a class="btn" href="{{ url_for(post.path) }}#more" rel="contents">
{{ __('post.read_more') }} &raquo;
</a>
</div>
{%- endif %}
<!--/noindex-->
{% else %}
{{ post.content }}
{%- endif %}
{% else %}
{{ post.content | safe }}
{%- endif %}
</div>

修补 post-collapse.njk

除了首页增加锁图标,在归档页面增加同样的逻辑

  • ./themes/next/layout/_macro/post-collapse.njk (修改)
{% macro render(posts) %}
{%- set current_year = '1970' %}
{%- for post in posts.toArray() %}

{%- set year = date(post.date, 'YYYY') %}

{%- if year !== current_year %}
{%- set current_year = year %}
<div class="collection-year">
<span class="collection-header">{{ current_year }}</span>
</div>
{%- endif %}

<article itemscope itemtype="http://schema.org/Article">
<header class="post-header">
<div class="post-meta-container">
<time itemprop="dateCreated"
datetime="{{ moment(post.date).format() }}"
content="{{ date(post.date, config.date_format) }}">
{{ date(post.date, 'MM-DD') }}
</time>
</div>

<div class="post-title">
{%- if post.link %}{# Link posts #}
{%- set postTitleIcon = '<i class="fa fa-external-link-alt"></i>' %}
{%- set postText = post.title or post.link %}
{{ next_url(post.link, postText + postTitleIcon, {class: 'post-title-link post-title-link-external', itemprop: 'url'}) }}
{% else %}
<a class="post-title-link" href="{{ url_for(post.path) }}" itemprop="url">
{########################################}
{########### 增加对加密文章的判断 ##########}
{########################################}
{%- if post.encrypted %}
<i class="fa fa-lock encrypted-lock-icon-small" id="lock-icon-small-{{ post.abbrlink }}"></i>
{%- endif %}
<script>
(function () {
const cacheKey = "{{ post.abbrlink }}";
try {
const cached = localStorage.getItem(cacheKey);
if (!cached) return;

const { expired } = JSON.parse(cached);
if (Date.now() >= expired) {
localStorage.removeItem(cacheKey);
return;
}

const lockIcon = document.getElementById("lock-icon-small-{{ post.abbrlink }}");
if (lockIcon) {
lockIcon.classList.add('decrypted');
lockIcon.className = lockIcon.className.replace('fa-lock', 'fa-unlock');
}
} catch (e) {}
})();
</script>
<span itemprop="name">{{ post.title or __('post.untitled') }}</span>
</a>
{%- endif %}
</div>

{{ post_gallery(post.photos) }}
</header>
</article>

{%- endfor %}
{% endmacro %}