← Back to Blog
EN中文

给加密博客补上最后一块拼图:端到端图片加密

问题

我的博客有一套双层加密体系:git-crypt 在 Git 层面加密源文件,前端通过 AES-256-GCM 在浏览器端解密文章。这个方案运行了一段时间后,一个显而易见的问题浮出水面——加密文章里的图片全部显示为破碎图标

原因很简单。加密文章的 Markdown 里用的是相对路径:

![Competition Dynamics](./images/cross_competition_dynamics.png)

但文章解密后是在 /blog/post.html?p=posts/2026/... 页面渲染的,浏览器会把相对路径解析成 /blog/images/cross_competition_dynamics.png——这个路径根本不存在。

方案选择

最直接的想法是把图片 Base64 编码后内嵌到 Markdown 里,加密时一起打包。但算了一笔账后放弃了:

  • 单张图片 60KB-300KB
  • Base64 编码后膨胀约 33%
  • 一篇文章引用 14 张图片的情况很常见
  • 内嵌后加密 JSON 可能膨胀到数 MB

最终方案是图片单独加密为独立文件

  1. 每张图片用文章的 contentKey 加密,生成独立的 .enc 文件
  2. Markdown 中的图片路径改写为指向加密文件的绝对路径
  3. 浏览器端解密文章后,异步解密所有图片并通过 Blob URL 显示

这样图片和文章共享同一把密钥,用户只需输入一次密码。

数据流

构建时 (Node.js)
═══════════════════════════════════════════════════════════

  Markdown 源文件                    图片源文件
  ./cross-experiment.md              ./images/foo.png
         │                                  │
         │  扫描 ![](./images/...)          │  读取二进制
         ▼                                  ▼
  路径改写为绝对路径              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 `![${alt}](/blog/encrypted/${outputSlugDir}/images/${imageName}.enc)`;
    });
}

构建输出示例:

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 分钟后自动清除。