Golang 中 AES-CBC 加密算法的 Writer 实现以及源码阅读

Golang 中官方包提供的功能基本都非常基本,对于一些特殊的要求,很多时候需要自己去实现。如果只是对于一个已知长度的数据块(比如文件、已知长度的字节数组)进行加解密的操作,那么根本不需要实现太过复杂的功能。

这里我主要以在下载加密的 m3u8 视频时,边写边解密以提升效率为目的,实现了一个能够去除 padding 的 CBC Writer。

https://github.com/keytouch/m3u8dl/tree/master/cbcio

在写这个包之前,我也查过一些 gist 上别人贴出来的实现,但我看到的实现方式都是在写入方法中是一个循环解密,但实际上 Go 提供的 cipher.BlockModeCryptBlocks 方法是支持多块一次性加解密的,并且对于长块的解密做了很大的优化。

CBC加解密的原理

CBC 加密 CBC 解密

图源Wikipedia

CBC (Cipher Block Chaining) 直译就是 加密块 链。也就是在加密的过程中,前一段的密文参与后一段的加密过程,先和明文做异或运算。这个参数就是 IV 初始化向量,IV 的长度跟块大小一样。

我们重点关注解密的过程,要解密,首先需要用密钥做块解密,之后是和 IV 进行异或运算,还原出明文。这里其实我们已经可以发现:我们每次如果顺着自然顺序,从前向后解密整个“块”链,那么我们如果用两块不同的源内存和目标内存来进行解密的话,那么确实不会造成任何问题,只要每次去取上一个原始密文的值就行了。

要节省内存?在同一块内存上就地完成解密,用现在的模式就需要将解密前的原始密文保存在一个临时内存中。但马上就会发现,这完全可以避免。我们从后往前解密,不就解决了嘛!原本的 lookbehind 操作就变成了 lookahead 操作。CryptBlocks 方法的源码:

1
2
3
4
5
6
7
8
for start > 0 {
    x.b.Decrypt(dst[start:end], src[start:end])
    xorBytes(dst[start:end], dst[start:end], src[prev:start])

    end = start
    start = prev
    prev -= x.blockSize
}

可以看到,Decrypt 方法是只负责解密的,只能操作单块,从最后一块开始,没解密完一块,不论源切片和目标切片的真实内存是不是同一块内存,都可以在接下来的 xorBytes 中直接 lookahead 取得前一块的密文(和当前块异或,得到明文)。

这种做法只需要考虑一种特殊情况:接收进来的密文的第一块,IV 需要特殊处理,并且接收进来的密文的最后一块的密文需要保存下来,供下次调用时作为下一段的 IV。

1
2
3
4
5
6
// The first block is special because it uses the saved iv.
x.b.Decrypt(dst[start:end], src[start:end])
xorBytes(dst[start:end], dst[start:end], x.iv)

// Set the new iv to the first block we copied earlier.
x.iv, x.tmp = x.tmp, x.iv

那么这样一看,如果我们在调用 CryptBlocks 时还来个循环调用,那真的是种浪费了。

我的解密过程实现

直接贴代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func (w *Writer) Write(p []byte) (n int, err error) {
	decryptLen := utils.Floor0(len(w.buf)+len(p), w.bSize)

	if decryptLen > 0 {
		// lazy make space for buf
		if w.decryptBuf == nil || len(w.decryptBuf) < decryptLen {
			w.decryptBuf = make([]byte, decryptLen)
		}

		// decrypt the buf and the heading part copied from p
		n += copy(w.buf[len(w.buf):w.bSize], p)
		w.blockMode.CryptBlocks(w.decryptBuf, w.buf[:w.bSize])
		// then decrypt the remaining part
		w.blockMode.CryptBlocks(w.decryptBuf[w.bSize:], p[n:decryptLen-len(w.buf)])

		w.buf = w.buf[:0]
		n += decryptLen - w.bSize

		_, err = w.wr.Write(w.decryptBuf[:decryptLen])
		if err != nil {
			return
		}
	}

	nn := copy(w.buf[len(w.buf):w.bSize], p[n:])
	w.buf = w.buf[:len(w.buf)+nn]
	n += nn

	return
}

这里主要对头尾做特殊处理,先计算新传入字节串的头部和上次缓存着的拼接后的长度,如果超过一块了,那么就去解密,并且将多余的不满一块的内容保存下来。

PKCS7 unpadding

PKCS7 的填充原理

PKCS7 的填充,粗略看过其实觉得挺简单优雅,也确实解决了非整块数据加密的问题。在原始数据之后追加重复字节,字节值就是欲添加的长度。

但很快我就有了疑问,如果这数据本来就是整块的呢?更甚的,如果这整块的数据的最后一块恰好满足 unpadding 的需要呢?比如:

00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
-----------------------------------------------
12 34 56 78 12 34 56 78 08 08 08 08 08 08 08 08

但其实,PKCS7规定,必须 padding!也就是,解密得到的明文的最后一定可以 妥妥 地 unpadding 掉!

思考一下,我得到的其实也是同样的方案,无论如何都加上这填充,解密的时候不就可以肆无忌惮地去除这段填充了嘛!

实现

相应的,只要在最后调用这个 Writer 的上下文中判断到输入流已经到了结尾了,那么就调用这个 Final 方法,判断一下 padding 的合法性并且截断到正确的位置写到指定的上游 Writer

在这里,核心的逻辑就是对 小于等于块大小 的块做维护,需要滞后写入这一块的内容,因为在这个 Writer 中我们无从获知是不是到了文件结尾,滞后最后一块的写入,就解决了问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Final will un-pad the last block and write everything to the underlying writer
func (w *Writer) Final() (err error) {
	if len(w.buf) != w.bSize {
		return errors.New("not a whole block buf when calling Final")
	}

	w.blockMode.CryptBlocks(w.buf, w.buf)

	padLen := int(w.buf[w.bSize-1])
	if padLen == 0 || padLen > w.bSize {
		return ErrBadPadding
	}
	for i := 2; i <= padLen; i++ {
		if int(w.buf[w.bSize-i]) != padLen {
			return ErrBadPadding
		}
	}

	_, err = w.wr.Write(w.buf[:w.bSize-padLen])
	return
}
updatedupdated2020-05-252020-05-25