给加密博客补上最后一块拼图:端到端图片加密
问题
我的博客有一套双层加密体系:git-crypt 在 Git 层面加密源文件,前端通过 AES-256-GCM 在浏览器端解密文章。这个方案运行了一段时间后,一个显而易见的问题浮出水面——加密文章里的图片全部显示为破碎图标。
原因很简单。加密文章的 Markdown 里用的是相对路径:

但文章解密后是在 /blog/post.html?p=posts/2026/... 页面渲染的,浏览器会把相对路径解析成 /blog/images/cross_competition_dynamics.png——这个路径根本不存在。
方案选择
最直接的想法是把图片 Base64 编码后内嵌到 Markdown 里,加密时一起打包。但算了一笔账后放弃了:
- 单张图片 60KB-300KB
- Base64 编码后膨胀约 33%
- 一篇文章引用 14 张图片的情况很常见
- 内嵌后加密 JSON 可能膨胀到数 MB
最终方案是图片单独加密为独立文件:
- 每张图片用文章的
contentKey加密,生成独立的.enc文件 - Markdown 中的图片路径改写为指向加密文件的绝对路径
- 浏览器端解密文章后,异步解密所有图片并通过 Blob URL 显示
这样图片和文章共享同一把密钥,用户只需输入一次密码。
数据流
构建时 (Node.js)
═══════════════════════════════════════════════════════════
Markdown 源文件 图片源文件
./cross-experiment.md ./images/foo.png
│ │
│ 扫描  │ 读取二进制
▼ ▼
路径改写为绝对路径 AES-256-GCM 加密
/blog/encrypted/.../foo.png.enc (同一个 contentKey)
│ │
▼ ▼
加密 Markdown 文本 写入 foo.png.enc
→ slug.json → {iv, ciphertext}
浏览器端
═══════════════════════════════════════════════════════════
用户输入密码
│
▼
PBKDF2 → 解密 slot → 获取 articleKey
│
▼
SHA-256(articleKey) → contentKey
│
├──→ 解密文章文本 → marked.js → HTML
│ │
│ 查找 img[src$=".enc"]
│ │
└──→ 并行 fetch 所有 .enc ──→ AES-GCM 解密
│
▼
Blob URL 替换 img.src
│
▼
图片正常显示
构建端实现
在 build-encrypted-posts.js 中新增 processMarkdownImages() 函数,在加密文章文本之前调用:
function processMarkdownImages(body, mdFilePath, articleKeyString, outputSlugDir) {
const imageRegex = /!\[([^\]]*)\]\(\.\/images\/([^)]+)\)/g;
const mdDir = path.dirname(mdFilePath);
const contentKey = crypto.createHash('sha256').update(articleKeyString).digest();
const processedImages = new Set();
// 第一遍:加密所有引用的图片
let match;
while ((match = imageRegex.exec(body)) !== null) {
const imageName = match[2];
if (processedImages.has(imageName)) continue;
processedImages.add(imageName);
const imageData = fs.readFileSync(path.join(mdDir, 'images', imageName));
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', contentKey, iv);
const encrypted = Buffer.concat([cipher.update(imageData), cipher.final()]);
const tag = cipher.getAuthTag();
// 输出为 JSON 格式的 .enc 文件
const encImageData = {
iv: iv.toString('base64'),
ciphertext: Buffer.concat([encrypted, tag]).toString('base64')
};
const encImagePath = path.join(OUTPUT_DIR, outputSlugDir, 'images', imageName + '.enc');
fs.mkdirSync(path.dirname(encImagePath), { recursive: true });
fs.writeFileSync(encImagePath, JSON.stringify(encImageData));
}
// 第二遍:改写 Markdown 中的图片路径
return body.replace(imageRegex, (_, alt, imageName) => {
return ``;
});
}
构建输出示例:
blog/encrypted/gol/
├── 2026-02-25-cross-experiment-en.json # 加密文章
├── 2026-02-25-cross-experiment-en/
│ └── images/
│ ├── cross_competition_dynamics.png.enc # 加密图片
│ ├── cross_competition_metrics.png.enc
│ └── cross_survival_dynamics.png.enc
浏览器端实现
修改 tryDecrypt() 返回内容密钥
原来 tryDecrypt() 只返回解密后的文本字符串。现在需要同时返回 contentKey 的 Base64 编码,用于后续解密图片:
async function tryDecrypt(password, encData) {
// ... PBKDF2 解密 slot,获取 articleKey ...
const contentKeyHash = await crypto.subtle.digest('SHA-256', enc.encode(articleKey));
const contentKey = await crypto.subtle.importKey(
'raw', contentKeyHash, 'AES-GCM', false, ['decrypt']
);
// 解密文章
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: contentIv }, contentKey, ciphertext
);
// 返回文本和密钥,密钥用于解密图片
const contentKeyB64 = btoa(String.fromCharCode(...new Uint8Array(contentKeyHash)));
return { plaintext: new TextDecoder().decode(plaintext), contentKeyB64 };
}
新增图片解密函数
文章渲染完成后,扫描所有 .enc 结尾的图片标签,并行解密:
async function decryptArticleImages(article, contentKeyB64) {
if (!contentKeyB64) return;
const images = article.querySelectorAll('img[src$=".enc"]');
if (images.length === 0) return;
const keyBytes = base64ToBuffer(contentKeyB64);
const contentKey = await crypto.subtle.importKey(
'raw', keyBytes, 'AES-GCM', false, ['decrypt']
);
await Promise.all(Array.from(images).map(async (img) => {
try {
img.style.opacity = '0.3'; // 加载中的视觉反馈
const resp = await fetch(img.src);
const encData = await resp.json();
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBuffer(encData.iv) },
contentKey,
base64ToBuffer(encData.ciphertext)
);
// 从文件名推断 MIME 类型
const ext = img.src.replace('.enc', '').split('.').pop().toLowerCase();
const mimeTypes = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml'
};
const blob = new Blob([decrypted], { type: mimeTypes[ext] || 'image/png' });
img.src = URL.createObjectURL(blob);
img.style.opacity = '';
} catch (e) {
console.error('Image decryption failed:', img.src, e);
img.style.opacity = '';
}
}));
}
Session Cache 也要存密钥
缓存中同时保存 contentKeyB64,这样从缓存加载文章时也能解密图片:
function setCachedDecrypt(slug, content, contentKeyB64) {
sessionStorage.setItem(CACHE_PREFIX + slug, JSON.stringify({
content, contentKeyB64, ts: Date.now()
}));
}
一个意外收获
开发过程中发现了一个 bug:_content/crypt-posts/gol/images/ 目录下的 .png 文件被构建系统当作博文扫描,23 张图片出现在了首页文章列表里。
原来这个目录里有一个 .no_posts 标记文件,意思是"这个目录不包含文章",但构建系统并没有检查它。一行代码修复:
function findPostFiles(dir, files = []) {
// 跳过包含 .no_posts 标记的目录
if (fs.existsSync(path.join(dir, '.no_posts'))) return files;
// ...
}
小结
这次改动解决了加密文章的最后一个痛点。整个方案的设计原则是密钥复用、文件分离:图片和文章使用同一个 contentKey 加密,但各自独立存储和传输。用户只需输入一次密码,文章文本和所有图片就会依次解密显示。
安全性方面,图片和文章的加密强度完全一致(AES-256-GCM),密钥的生命周期也是一样的——在 sessionStorage 中缓存 15 分钟后自动清除。