如何在zola这样的静态博客框架中实现内容的加密呢?

前言

由于备案到期外加服务器也即将到期,故将博客迁移到静态pages上,具体的迁移过程也足够写一篇blog来介绍了

最终选用的框架是zola + cloudflare pages

折腾zola的过程中发现,好像在代码层面并没有实现加密功能,最多只有一个draft,草稿功能。

于是决定自己动手写一个静态的加密,具体的实现效果,下面就是一个例子,密码114514:

Encrypt Demo

下面分享一下具体实现这个功能的过程

加解密算法

之前在GitHub page上使用过一段时间的hexo静态博客框架,这个框架由于是使用前端统一构建,所以可以基于js写一堆plugins,其中加密插件我见过写的比较好的就是hexo-blog-encrypt,所以使用同样的AES-CBC算法,iv使用sha256后的key

具体的实现方式就是在前端js里面实现解密

function aesDecrypt(data, key) {
  if (key.length > 32) key = key.slice(0, 32)
  const cypherKey = CryptoJS.enc.Utf8.parse(key)
  CryptoJS.pad.ZeroPadding.pad(cypherKey, 4)
  const iv = CryptoJS.SHA256(key).toString()
  const cfg = { iv: CryptoJS.enc.Utf8.parse(iv) }
  const decrypted = CryptoJS.AES.decrypt(data, cypherKey, cfg)
  return decrypted.toString(CryptoJS.enc.Utf8)
}

但是,怎么做到在zola进行模板渲染的时候,就能够获取AES加密后的文章数据呢?

Cloudflare Pages Build

在cloudflare pages的config页面,可以自定义构建启动命令,比如我的启动命令目前就是zola build,会将页面构建到public文件夹下,Cloudflare Page就会自动渲染这个文件夹下的页面。

image-20240229144858998

具体的构建环境,也可以配置,目前官方默认使用的v2版本就自带各种语言

image-20240229145015358

让我们回到正题,如何在zola build的前后任意一个时间完成加密文章的加密处理

我这里的操作是,在zola项目的根目录写上一个golang的源码init.go,具体的逻辑就是对需要加密的文件进行aes加密,文件的第一行默认是aes的key,然后对之后的数据进行加密处理,并写会原文件。

在执行go run init.go之后,再执行zola build,就可以实现在zola运行之前就将文章进行加密了,这样操作直接使用go run init.go && zola build的构建命令就可以完成了。具体的golang代码如下

package main

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

type PaddingMode string

const PKCS5 PaddingMode = "PKCS5"
const PKCS7 PaddingMode = "PKCS7"
const ZEROS PaddingMode = "ZEROS"

func Padding(padding PaddingMode, src []byte, blockSize int) []byte {
	switch padding {
	case PKCS5:
		src = PKCS5Padding(src, blockSize)
	case PKCS7:
		src = PKCS7Padding(src, blockSize)
	case ZEROS:
		src = ZerosPadding(src, blockSize)
	}
	return src
}

func UnPadding(padding PaddingMode, src []byte) ([]byte, error) {
	switch padding {
	case PKCS5:
		return PKCS5UnPadding(src)
	case PKCS7:
		return PKCS7UnPadding(src)
	case ZEROS:
		return ZerosUnPadding(src)
	}
	return src, nil
}

func PKCS5Padding(src []byte, blockSize int) []byte {
	return PKCS7Padding(src, blockSize)
}

func PKCS5UnPadding(src []byte) ([]byte, error) {
	return PKCS7UnPadding(src)
}

func PKCS7Padding(src []byte, blockSize int) []byte {
	padding := blockSize - len(src)%blockSize
	padtext := bytes.Repeat([]byte{byte(padding)}, padding)
	return append(src, padtext...)
}

func PKCS7UnPadding(src []byte) ([]byte, error) {
	length := len(src)
	if length == 0 {
		return src, fmt.Errorf("src length is 0")
	}
	unpadding := int(src[length-1])
	if length < unpadding {
		return src, fmt.Errorf("src length is less than unpadding")
	}
	return src[:(length - unpadding)], nil
}

func ZerosPadding(src []byte, blockSize int) []byte {
	rem := len(src) % blockSize
	if rem == 0 {
		return src
	}
	return append(src, bytes.Repeat([]byte{0}, blockSize-rem)...)
}

func ZerosUnPadding(src []byte) ([]byte, error) {
	for i := len(src) - 1; ; i-- {
		if src[i] != 0 {
			return src[:i+1], nil
		}
	}
}

func AesSimpleEncrypt(data, key string) string {
	key = trimByMaxKeySize(key)
	keyBytes := ZerosPadding([]byte(key), aes.BlockSize)
	return AesCBCEncrypt(data, string(keyBytes), GenIVFromKey(key), PKCS7)
}

func AesCBCEncrypt(data, key, iv string, paddingMode PaddingMode) string {
	block, err := aes.NewCipher([]byte(key))
	if err != nil {
		return ""
	}

	src := Padding(paddingMode, []byte(data), block.BlockSize())
	encryptData := make([]byte, len(src))
	mode := cipher.NewCBCEncrypter(block, []byte(iv))
	mode.CryptBlocks(encryptData, src)
	return base64.StdEncoding.EncodeToString(encryptData)
}

func AesSimpleDecrypt(data, key string) string {
	key = trimByMaxKeySize(key)
	keyBytes := ZerosPadding([]byte(key), aes.BlockSize)
	return AesCBCDecrypt(data, string(keyBytes), GenIVFromKey(key), PKCS7)
}

func AesCBCDecrypt(data, key, iv string, paddingMode PaddingMode) string {
	block, err := aes.NewCipher([]byte(key))
	if err != nil {
		return ""
	}
	decodeData, _ := base64.StdEncoding.DecodeString(data)
	decryptData := make([]byte, len(decodeData))
	mode := cipher.NewCBCDecrypter(block, []byte(iv))
	mode.CryptBlocks(decryptData, decodeData)
	original, _ := UnPadding(paddingMode, decryptData)
	return string(original)
}

func GenIVFromKey(key string) (iv string) {
	hashedKey := sha256.Sum256([]byte(key))
	return trimByBlockSize(hex.EncodeToString(hashedKey[:]))
}

func trimByBlockSize(key string) string {
	if len(key) > aes.BlockSize {
		return key[:aes.BlockSize]
	}
	return key
}

func trimByMaxKeySize(key string) string {
	if len(key) > 32 {
		return key[:32]
	}
	return key
}

func Enc(folderPath string) {
	if folderPath == "" {
		return
	}
	if err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !info.IsDir() {
			file, err := os.Open(path)
			if err != nil {
				return err
			}
			defer file.Close()
			content, err := io.ReadAll(file)
			if err != nil {
				return err
			}
			cont := string(content)
			conts := strings.SplitN(cont, "\n", 2)
			key := conts[0]
			cont = conts[1]
			aesedContent := AesSimpleEncrypt(cont, key)

			err = os.WriteFile(path, []byte(aesedContent), info.Mode())
			if err != nil {
				return err
			}
		}
		return nil
	}); err != nil {
		panic(err)
	}
}

func main() {
	Enc("encrypt")
}

来一段可以运行演示的代码:(至于这段代码我是如何做到能够动态运行的,之后再用一篇文章详细介绍)

上述代码可以实现AES-CBC的加解密操作,这里只用到了加密操作。

运行又encrypt文件夹下的所有文件都被AES加密了一次,但是如何使用这些加密后的数据呢?

shortcode

zola有shortcode这样的功能,于是就可以配合golang加密后的文件进行操作

在zola的内置function中,有一个load-data,可以访问远程资源或本地资源,于是可以通过这个函数去获取加密后的AES数据,之后通过JS的方式进行数据的解密。

最终的shortcode

{% if src %}
{% set data = load_data(path="encrypt/" ~ src) %}
{% set hash = get_hash(literal=src, sha_type=256) %}

<div id="secret-content-{{ hash }}">
    <p><code>{% if msg %}{{ msg }}{% else -%} 以下内容已被加密,请输入密码后查看 {%- endif %}</code></p>
    <input id="pwd-{{hash}}" class="mo-input" name="password" type="password" autocomplete="new-password"
        placeholder="password" aria-label="password">
    <input id="data-{{ hash }}" name="data" type="hidden" value="{{ data }}">
    <button type="submit" style="display: inline-flex;" onclick="decryptContent('{{hash}}')">解密</button>
</div>
<div id="decrypted-content-{{ hash }}"></div>
{% endif %}

这里的decryptContent函数的具体实现写在全局js中了

function decryptContent(hash) {
    const pwd = document.getElementById(`pwd-${hash}`).value
    const data = document.getElementById(`data-${hash}`).value
    if (!pwd || !data) return
    const res = aesDecrypt(data, pwd)
    if (!res) {
        document.getElementById(`pwd-${hash}`).value = ''
        return
    }
    document.getElementById(`secret-content-${hash}`).remove()
    document.getElementById(`decrypted-content-${hash}`).innerHTML += marked.parse(res)
}

至于为什么需要用到get-hash这样的函数,是因为如果一篇文章中需要加密多段数据,需要唯一的id去让js顺利找到标签,随后将解密的数据给写上去。

同时,由于本地的数据基本都是用md编写的,需要在前端进行md解析的操作,这里用到的是marked

具体的使用方式,例如本文第一段实现的加密,在md文件中的使用方式就是

\{\{ encrypt(src="test.md", msg="Encrypt Demo") \}\}

test.md中具体的内容就是

114514

`简单的加密测试...`

第一行为加密使用的key,第二行就是内容。

至此,一个简单的加密shortcode就完成了,可以在任意文章中加密任意次数的数据