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: 不能,原因如下

攻击者拥有:
1. 加密的文章内容 (ciphertext)
2. 初始化向量 (iv, authTag)
3. 包装后的 CEK (wrappedKeys)
4. PRF Salt

攻击者缺少:
❌ 包装密钥 (wrappingKey)
└─ 使用 AES-256-GCM 加密,安全性目前尚可

核心代码

const crypto = require("crypto");

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 {
encryptedCEK: encrypted.toString("base64"),
iv: iv.toString("base64"),
authTag: cipher.getAuthTag().toString("base64"),
};
}

function genTemplate(abbrlink, encData, wrappedKeys, prfSalt) {
const wrappedKeysJson = JSON.stringify(wrappedKeys).replace(/"/g, '"');
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}">
<div class="encrypted-post-notice">
<button class="fido2-verify-btn" id="fido2-verify-btn"><i class="fa fa-fingerprint"></i> 验证身份</button>
<div class="verification-status" id="verification-status"></div>
</div>
<div class="decrypted-content" id="decrypted-content" style="display:none"></div>
</div>`.trim();
}

hexo.extend.filter.register("after_post_render", function (data) {
const cfg = hexo.config.encryption;
if (!data.encrypted) return data;

if (!cfg || !cfg.enabled) {
hexo.log.warn(`${data.title}: 加密功能未启用`);
return data;
}

if (!cfg.salt) {
hexo.log.error(`${data.title}: 缺少 PRF Salt 配置`);
return data;
}

const keys = Array.isArray(cfg.keys) ? cfg.keys : cfg.key ? [cfg.key] : [];
if (keys.length === 0) {
hexo.log.error("请先注册 FIDO2");
return data;
}

try {
const cek = crypto.randomBytes(32);
const encData = encrypt(data.content, cek.toString("hex"));
const wrappedKeys = keys.map(wrapKey => encryptCEK(cek, wrapKey));
data.content = genTemplate(data.abbrlink, encData, wrappedKeys, cfg.salt);
hexo.log.info(`Encrypted: ${data.title}`);
} catch (err) {
hexo.log.error(`Encrypted: ${data.title} ${err}`);
}
return data;
}, 15);

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

class FIDO2Decryptor {
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,
};

if (!window.PublicKeyCredential) {
this.showError("浏览器不支持 FIDO2/WebAuthn");
return;
}

if (this.checkCache()) return;

const btn = document.getElementById("fido2-verify-btn");
if (!btn) return;

btn.onclick = () => this.authenticate();
}

getCacheKey() {
return this.data.abbrlink || location.pathname;
}

checkCache() {
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) {
try {
const expired = Date.now() + 10 * 60 * 1000;
localStorage.setItem(
this.getCacheKey(),
JSON.stringify({ html, expired })
);
} catch (e) {
console.warn(`缓存失败: ${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.showError("验证被拒绝");
} else if (e.name === "InvalidStateError" || e.name === "NotFoundError") {
this.showError("未注册的通行密钥");
} else {
this.showError(`验证失败: ${e.message}`);
}
}
}

async unwrapAndDecrypt(wrappingKey) {
try {
const wrapKeyBuffer = await crypto.subtle.importKey(
"raw",
wrappingKey,
{ name: "AES-GCM" },
false,
["decrypt"]
);

let cek = null;
for (const wrapped of this.data.wrappedKeys) {
try {
const encCEK = this.b64ToAB(wrapped.encryptedCEK);
const wrapIV = this.b64ToAB(wrapped.iv);
const wrapAuthTag = this.b64ToAB(wrapped.authTag);

const wrappedCEK = new Uint8Array(
encCEK.byteLength + wrapAuthTag.byteLength
);
wrappedCEK.set(new Uint8Array(encCEK), 0);
wrappedCEK.set(new Uint8Array(wrapAuthTag), encCEK.byteLength);

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

if (!cek) throw new Error("通行密钥无权限");

await this.decryptContent(cek);
} catch (e) {
this.showError(`解密失败: ${e.message}`);
}
}

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 = new Uint8Array(
ciphertext.byteLength + authTag.byteLength
);
encData.set(new Uint8Array(ciphertext), 0);
encData.set(new Uint8Array(authTag), ciphertext.byteLength);

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.showError(`解密失败: ${e.message}`);
}
}

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";

try {
if (typeof NexT !== "undefined" && NexT.boot?.refresh) {
NexT.boot.refresh();
}
} catch (e) {
console.warn(`NexT 主题功能初始化失败: ${e}`);
}
}

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}`;
}

showError(msg) {
this.showStatus(msg, "error");
}

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

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 FIDO2Decryptor();
document.addEventListener("DOMContentLoaded", () => {
if (document.querySelector(".encrypted-post-container")) decryptor.init();
});

效果展示